diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index d1ea4e8fb..fd0c45456 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -11,9 +11,11 @@ using API.Extensions; using API.Interfaces; using API.Interfaces.Services; using API.Services; +using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; namespace API.Controllers { @@ -25,16 +27,19 @@ namespace API.Controllers private readonly IDirectoryService _directoryService; private readonly ICacheService _cacheService; private readonly IDownloadService _downloadService; + private readonly IHubContext _messageHub; private readonly NumericComparer _numericComparer; private const string DefaultContentType = "application/octet-stream"; - public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService, IDownloadService downloadService) + public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, + ICacheService cacheService, IDownloadService downloadService, IHubContext messageHub) { _unitOfWork = unitOfWork; _archiveService = archiveService; _directoryService = directoryService; _cacheService = cacheService; _downloadService = downloadService; + _messageHub = messageHub; _numericComparer = new NumericComparer(); } @@ -67,13 +72,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { - if (files.Count == 1) - { - return await GetFirstFileDownload(files); - } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - $"download_{User.GetUsername()}_v{volumeId}"); - return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_v{volumeId}", $"{series.Name} - Volume {volume.Number}.zip"); } catch (KavitaException ex) { @@ -96,13 +95,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { - if (files.Count == 1) - { - return await GetFirstFileDownload(files); - } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - $"download_{User.GetUsername()}_c{chapterId}"); - return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series.Name} - Chapter {chapter.Number}.zip"); } catch (KavitaException ex) { @@ -110,6 +103,21 @@ namespace API.Controllers } } + private async Task DownloadFiles(ICollection files, string tempFolder, string downloadName) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 0F)); + if (files.Count == 1) + { + return await GetFirstFileDownload(files); + } + var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), + tempFolder); + await _messageHub.Clients.All.SendAsync(SignalREvents.DownloadProgress, + MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(downloadName), 1F)); + return File(fileBytes, DefaultContentType, downloadName); + } + [HttpGet("series")] public async Task DownloadSeries(int seriesId) { @@ -117,13 +125,7 @@ namespace API.Controllers var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); try { - if (files.Count == 1) - { - return await GetFirstFileDownload(files); - } - var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath), - $"download_{User.GetUsername()}_s{seriesId}"); - return File(fileBytes, DefaultContentType, $"{series.Name}.zip"); + return await DownloadFiles(files, $"download_{User.GetUsername()}_s{seriesId}", $"{series.Name}.zip"); } catch (KavitaException ex) { @@ -187,5 +189,6 @@ namespace API.Controllers DirectoryService.ClearAndDeleteDirectory(fullExtractPath); return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip"); } + } } diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 49a70d90d..ac28a52e1 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -583,7 +583,7 @@ namespace API.Controllers feed.Links.Add(CreateLink(FeedLinkRelation.Prev, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber - 1))); } - if (pageNumber + 1 < list.TotalPages) + if (pageNumber + 1 <= list.TotalPages) { feed.Links.Add(CreateLink(FeedLinkRelation.Next, FeedLinkType.AtomNavigation, url + "pageNumber=" + (pageNumber + 1))); } @@ -596,7 +596,7 @@ namespace API.Controllers } - feed.Total = list.TotalPages * list.PageSize; + feed.Total = list.TotalCount; feed.ItemsPerPage = list.PageSize; feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1; } diff --git a/API/Extensions/IdentityServiceExtensions.cs b/API/Extensions/IdentityServiceExtensions.cs index 9b32c9320..1d0638e67 100644 --- a/API/Extensions/IdentityServiceExtensions.cs +++ b/API/Extensions/IdentityServiceExtensions.cs @@ -17,8 +17,13 @@ namespace API.Extensions { services.AddIdentityCore(opt => { - // Change password / signin requirements here opt.Password.RequireNonAlphanumeric = false; + opt.Password.RequireDigit = false; + opt.Password.RequireDigit = false; + opt.Password.RequireLowercase = false; + opt.Password.RequireUppercase = false; + opt.Password.RequireNonAlphanumeric = false; + opt.Password.RequiredLength = 6; }) .AddRoles() .AddRoleManager>() diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 77c745535..1af8d47dc 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -120,7 +120,7 @@ namespace API.Services { _logger.LogInformation("Scheduling Auto-Update tasks"); // Schedule update check between noon and 6pm local time - RecurringJob.AddOrUpdate("check-updates", () => _versionUpdaterService.CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); + RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(12, 18)), TimeZoneInfo.Local); } #endregion diff --git a/API/Services/Tasks/VersionUpdaterService.cs b/API/Services/Tasks/VersionUpdaterService.cs index 64e21d39a..ff9e43462 100644 --- a/API/Services/Tasks/VersionUpdaterService.cs +++ b/API/Services/Tasks/VersionUpdaterService.cs @@ -86,10 +86,6 @@ namespace API.Services.Tasks return CreateDto(update); } - /// - /// - /// - /// public async Task> GetAllReleases() { var updates = await GetGithubReleases(); @@ -140,13 +136,7 @@ namespace API.Services.Tasks private async Task SendEvent(UpdateNotificationDto update, IReadOnlyList admins) { - var connections = new List(); - foreach (var admin in admins) - { - connections.AddRange(await _tracker.GetConnectionsForUser(admin)); - } - - await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateVersion, MessageFactory.UpdateVersionEvent(update)); + await _messageHub.Clients.Users(admins).SendAsync(SignalREvents.UpdateAvailable, MessageFactory.UpdateVersionEvent(update)); } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index 3ab6c646c..982d82f91 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -118,7 +118,7 @@ namespace API.SignalR { return new SignalRMessage { - Name = SignalREvents.UpdateVersion, + Name = SignalREvents.UpdateAvailable, Body = update }; } @@ -127,7 +127,7 @@ namespace API.SignalR { return new SignalRMessage { - Name = SignalREvents.UpdateVersion, + Name = SignalREvents.UpdateAvailable, Body = new { TagId = tagId, @@ -147,5 +147,19 @@ namespace API.SignalR } }; } + + public static SignalRMessage DownloadProgressEvent(string username, string downloadName, float progress) + { + return new SignalRMessage() + { + Name = SignalREvents.DownloadProgress, + Body = new + { + UserName = username, + DownloadName = downloadName, + Progress = progress + } + }; + } } } diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index 06908c2be..4a82191ed 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -2,7 +2,7 @@ { public static class SignalREvents { - public const string UpdateVersion = "UpdateVersion"; + public const string UpdateAvailable = "UpdateAvailable"; public const string ScanSeries = "ScanSeries"; /// /// Event during Refresh Metadata for cover image change @@ -27,5 +27,10 @@ /// Event sent out during cleaning up temp and cache folders /// public const string CleanupProgress = "CleanupProgress"; + /// + /// Event sent out during downloading of files + /// + public const string DownloadProgress = "DownloadProgress"; + } } diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 5ada05dc2..e8f98d497 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -23,6 +23,7 @@ export type VolumeActionCallback = (volume: Volume) => void; export type ChapterActionCallback = (chapter: Chapter) => void; export type ReadingListActionCallback = (readingList: ReadingList) => void; export type VoidActionCallback = () => void; +export type BooleanActionCallback = (result: boolean) => void; /** * Responsible for executing actions @@ -138,6 +139,9 @@ export class ActionService implements OnDestroy { */ async refreshMetdata(series: Series, callback?: SeriesActionCallback) { if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + if (callback) { + callback(series); + } return; } @@ -484,4 +488,20 @@ export class ActionService implements OnDestroy { }); } + async deleteSeries(series: Series, callback?: BooleanActionCallback) { + if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) { + if (callback) { + callback(false); + } + return; + } + + this.seriesService.delete(series.id).subscribe((res: boolean) => { + if (callback) { + this.toastr.success('Series deleted'); + callback(res); + } + }); + } + } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index 85a543322..259f2a4c1 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -24,7 +24,8 @@ export enum EVENTS { SeriesAddedToCollection = 'SeriesAddedToCollection', ScanLibraryError = 'ScanLibraryError', BackupDatabaseProgress = 'BackupDatabaseProgress', - CleanupProgress = 'CleanupProgress' + CleanupProgress = 'CleanupProgress', + DownloadProgress = 'DownloadProgress' } export interface Message { @@ -38,7 +39,6 @@ export interface Message { export class MessageHubService { hubUrl = environment.hubUrl; private hubConnection!: HubConnection; - private updateNotificationModalRef: NgbModalRef | null = null; private messagesSource = new ReplaySubject>(1); public messages$ = this.messagesSource.asObservable(); @@ -53,7 +53,7 @@ export class MessageHubService { isAdmin: boolean = false; - constructor(private modalService: NgbModal, private toastr: ToastrService, private router: Router) { + constructor(private toastr: ToastrService, private router: Router) { } @@ -106,6 +106,13 @@ export class MessageHubService { }); }); + this.hubConnection.on(EVENTS.DownloadProgress, resp => { + this.messagesSource.next({ + event: EVENTS.DownloadProgress, + payload: resp.body + }); + }); + this.hubConnection.on(EVENTS.RefreshMetadataProgress, resp => { this.messagesSource.next({ event: EVENTS.RefreshMetadataProgress, @@ -162,16 +169,6 @@ export class MessageHubService { event: EVENTS.UpdateAvailable, payload: resp.body }); - // Ensure only 1 instance of UpdateNotificationModal can be open at once - if (this.updateNotificationModalRef != null) { return; } - this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' }); - this.updateNotificationModalRef.componentInstance.updateData = resp.body; - this.updateNotificationModalRef.closed.subscribe(() => { - this.updateNotificationModalRef = null; - }); - this.updateNotificationModalRef.dismissed.subscribe(() => { - this.updateNotificationModalRef = null; - }); }); } diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 11d869401..cd0fdb701 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -132,35 +132,26 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { }); } - refreshMetdata(series: Series) { - this.seriesService.refreshMetadata(series).subscribe((res: any) => { - this.toastr.success('Refresh started for ' + series.name); - }); + async refreshMetdata(series: Series) { + this.actionService.refreshMetdata(series); } - scanLibrary(series: Series) { + async scanLibrary(series: Series) { this.seriesService.scan(series.libraryId, series.id).subscribe((res: any) => { this.toastr.success('Scan started for ' + series.name); }); } async deleteSeries(series: Series) { - if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) { - return; - } - - this.seriesService.delete(series.id).subscribe((res: boolean) => { - if (res) { - this.toastr.success('Series deleted'); + this.actionService.deleteSeries(series, (result: boolean) => { + if (result) { this.reload.emit(true); } }); } markAsUnread(series: Series) { - this.seriesService.markUnread(series.id).subscribe(res => { - this.toastr.success(series.name + ' is now unread'); - series.pagesRead = 0; + this.actionService.markSeriesAsUnread(series, () => { if (this.data) { this.data.pagesRead = 0; } @@ -170,9 +161,7 @@ export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { } markAsRead(series: Series) { - this.seriesService.markRead(series.id).subscribe(res => { - this.toastr.success(series.name + ' is now read'); - series.pagesRead = series.pages; + this.actionService.markSeriesAsRead(series, () => { if (this.data) { this.data.pagesRead = series.pages; } diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html index 36da7d079..69be1191f 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.html @@ -1,6 +1,6 @@ - @@ -14,9 +14,12 @@ Scan for {{event.libraryName}} in progress {{prettyPrintProgress(event.progress)}}% - {{prettyPrintEvent(event.eventType)}} {{event.libraryName}} + {{prettyPrintEvent(event.eventType, event)}} {{event.libraryName}} + +
  • Not much going on here
  • +
  • +  Update available
  • -
  • Not much going on here
  • \ No newline at end of file diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss index b4122d7c6..7ab851b81 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.scss @@ -20,4 +20,13 @@ .colored { background-color: colors.$primary-color; border-radius: 60px; +} + +.update-available { + cursor: pointer; + + i.fa { + color: colors.$primary-color !important; + } + color: colors.$primary-color; } \ No newline at end of file diff --git a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts index 26b77bbfd..26de71660 100644 --- a/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts +++ b/UI/Web/src/app/nav-events-toggle/nav-events-toggle.component.ts @@ -1,6 +1,8 @@ import { Component, Input, OnDestroy, OnInit } from '@angular/core'; +import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; import { BehaviorSubject, Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; +import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; import { ProgressEvent } from '../_models/events/scan-library-progress-event'; import { User } from '../_models/user'; import { LibraryService } from '../_services/library.service'; @@ -16,6 +18,8 @@ interface ProcessedEvent { type ProgressType = EVENTS.ScanLibraryProgress | EVENTS.RefreshMetadataProgress | EVENTS.BackupDatabaseProgress | EVENTS.CleanupProgress; +const acceptedEvents = [EVENTS.ScanLibraryProgress, EVENTS.RefreshMetadataProgress, EVENTS.BackupDatabaseProgress, EVENTS.CleanupProgress, EVENTS.DownloadProgress]; + @Component({ selector: 'app-nav-events-toggle', templateUrl: './nav-events-toggle.component.html', @@ -33,7 +37,11 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { progressEventsSource = new BehaviorSubject([]); progressEvents$ = this.progressEventsSource.asObservable(); - constructor(private messageHub: MessageHubService, private libraryService: LibraryService) { } + updateAvailable: boolean = false; + updateBody: any; + private updateNotificationModalRef: NgbModalRef | null = null; + + constructor(private messageHub: MessageHubService, private libraryService: LibraryService, private modalService: NgbModal) { } ngOnDestroy(): void { this.onDestroy.next(); @@ -43,8 +51,11 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { ngOnInit(): void { this.messageHub.messages$.pipe(takeUntil(this.onDestroy)).subscribe(event => { - if (event.event === EVENTS.ScanLibraryProgress || event.event === EVENTS.RefreshMetadataProgress || event.event === EVENTS.BackupDatabaseProgress || event.event === EVENTS.CleanupProgress) { + if (acceptedEvents.includes(event.event)) { this.processProgressEvent(event, event.event); + } else if (event.event === EVENTS.UpdateAvailable) { + this.updateAvailable = true; + this.updateBody = event.payload; } }); } @@ -64,7 +75,7 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { if (scanEvent.progress !== 1) { const libraryName = names[scanEvent.libraryId] || ''; - const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName}; + const newEvent = {eventType: eventType, timestamp: scanEvent.eventTime, progress: scanEvent.progress, libraryId: scanEvent.libraryId, libraryName, rawBody: event.payload}; data.push(newEvent); } @@ -73,16 +84,29 @@ export class NavEventsToggleComponent implements OnInit, OnDestroy { }); } + handleUpdateAvailableClick() { + if (this.updateNotificationModalRef != null) { return; } + this.updateNotificationModalRef = this.modalService.open(UpdateNotificationModalComponent, { scrollable: true, size: 'lg' }); + this.updateNotificationModalRef.componentInstance.updateData = this.updateBody; + this.updateNotificationModalRef.closed.subscribe(() => { + this.updateNotificationModalRef = null; + }); + this.updateNotificationModalRef.dismissed.subscribe(() => { + this.updateNotificationModalRef = null; + }); + } + prettyPrintProgress(progress: number) { return Math.trunc(progress * 100); } - prettyPrintEvent(eventType: string) { + prettyPrintEvent(eventType: string, event: any) { switch(eventType) { case (EVENTS.ScanLibraryProgress): return 'Scanning '; case (EVENTS.RefreshMetadataProgress): return 'Refreshing '; case (EVENTS.CleanupProgress): return 'Clearing Cache'; case (EVENTS.BackupDatabaseProgress): return 'Backing up Database'; + case (EVENTS.DownloadProgress): return event.rawBody.userName.charAt(0).toUpperCase() + event.rawBody.userName.substr(1) + ' is downloading ' + event.rawBody.downloadName; default: return eventType; } } diff --git a/UI/Web/src/app/register-member/register-member.component.html b/UI/Web/src/app/register-member/register-member.component.html index 84a7983a8..5b757f889 100644 --- a/UI/Web/src/app/register-member/register-member.component.html +++ b/UI/Web/src/app/register-member/register-member.component.html @@ -12,8 +12,12 @@
    - - +   + + Password must be between 6 and 32 characters in length + + +
    diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 356d99657..bbcb05871 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -302,17 +302,11 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { async deleteSeries(series: Series) { - if (!await this.confirmService.confirm('Are you sure you want to delete this series? It will not modify files on disk.')) { + this.actionService.deleteSeries(series, (result: boolean) => { this.actionInProgress = false; - return; - } - - this.seriesService.delete(series.id).subscribe((res: boolean) => { - if (res) { - this.toastr.success('Series deleted'); + if (result) { this.router.navigate(['library', this.libraryId]); } - this.actionInProgress = false; }); } diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index efd0dc009..6b5bc22d0 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -1,7 +1,7 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule } from '@angular/forms'; -import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'; +import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; import { ConfirmDialogComponent } from './confirm-dialog/confirm-dialog.component'; import { SafeHtmlPipe } from './safe-html.pipe'; import { RegisterMemberComponent } from '../register-member/register-member.component'; @@ -37,6 +37,7 @@ import { SentenceCasePipe } from './sentence-case.pipe'; RouterModule, ReactiveFormsModule, NgbCollapseModule, + NgbTooltipModule, // RegisterMemberComponent NgCircleProgressModule.forRoot(), ], exports: [