diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index beadd2a95..22f8cf90c 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -16,9 +16,11 @@ using API.Services; using API.Services.Tasks.Scanner; using API.SignalR; using AutoMapper; +using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; +using TaskScheduler = API.Services.TaskScheduler; namespace API.Controllers { @@ -133,7 +135,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(updateLibraryForUserDto.Username); if (user == null) return BadRequest("Could not validate user"); - var libraryString = String.Join(",", updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); + var libraryString = string.Join(",", updateLibraryForUserDto.SelectedLibraries.Select(x => x.Name)); _logger.LogInformation("Granting user {UserName} access to: {Libraries}", updateLibraryForUserDto.Username, libraryString); var allLibraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); @@ -242,10 +244,16 @@ namespace API.Controllers var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(seriesIds); - try { var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId)) + { + // TODO: Figure out how to cancel a job + _logger.LogInformation("User is attempting to delete a library while a scan is in progress"); + return BadRequest( + "You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete"); + } _unitOfWork.LibraryRepository.Delete(library); await _unitOfWork.CommitAsync(); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 232b02f24..c9393ac9a 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -11,6 +11,7 @@ using API.Entities; using API.Entities.Enums; using API.Extensions; using API.Services; +using API.Services.Tasks; using Hangfire; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; diff --git a/API/DTOs/Reader/BookmarkDto.cs b/API/DTOs/Reader/BookmarkDto.cs index 3653bcaa0..33f55cf8d 100644 --- a/API/DTOs/Reader/BookmarkDto.cs +++ b/API/DTOs/Reader/BookmarkDto.cs @@ -1,11 +1,17 @@ -namespace API.DTOs.Reader +using System.ComponentModel.DataAnnotations; + +namespace API.DTOs.Reader { public class BookmarkDto { public int Id { get; set; } + [Required] public int Page { get; set; } + [Required] public int VolumeId { get; set; } + [Required] public int SeriesId { get; set; } + [Required] public int ChapterId { get; set; } } } diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index 361d77737..2862af38f 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -44,6 +44,7 @@ public interface IMetadataService public class MetadataService : IMetadataService { + public const string Name = "MetadataService"; private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IEventHub _eventHub; diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index f61a77a9e..12e6d94c8 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -50,6 +50,9 @@ public class TaskScheduler : ITaskScheduler public const string ScanQueue = "scan"; public const string DefaultQueue = "default"; + public static readonly IList ScanTasks = new List() + {"ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"}; + private static readonly Random Rnd = new Random(); @@ -172,7 +175,7 @@ public class TaskScheduler : ITaskScheduler public void ScanLibraries() { - if (RunningAnyTasksByMethod(new List() {"ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"}, ScanQueue)) + if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { _logger.LogInformation("A Scan is already running, rescheduling ScanLibraries in 3 hours"); BackgroundJob.Schedule(() => ScanLibraries(), TimeSpan.FromHours(3)); @@ -191,7 +194,7 @@ public class TaskScheduler : ITaskScheduler _logger.LogInformation("A duplicate request to scan library for library occured. Skipping"); return; } - if (RunningAnyTasksByMethod(new List() {"ScannerService", "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"}, ScanQueue)) + if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { _logger.LogInformation("A Scan is already running, rescheduling ScanLibrary in 3 hours"); BackgroundJob.Schedule(() => ScanLibrary(libraryId, force), TimeSpan.FromHours(3)); @@ -211,7 +214,7 @@ public class TaskScheduler : ITaskScheduler public void RefreshMetadata(int libraryId, bool forceUpdate = true) { - var alreadyEnqueued = HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary", + var alreadyEnqueued = HasAlreadyEnqueuedTask(MetadataService.Name, "GenerateCoversForLibrary", new object[] {libraryId, true}) || HasAlreadyEnqueuedTask("MetadataService", "GenerateCoversForLibrary", new object[] {libraryId, false}); @@ -227,7 +230,7 @@ public class TaskScheduler : ITaskScheduler public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false) { - if (HasAlreadyEnqueuedTask("MetadataService","GenerateCoversForSeries", new object[] {libraryId, seriesId, forceUpdate})) + if (HasAlreadyEnqueuedTask(MetadataService.Name,"GenerateCoversForSeries", new object[] {libraryId, seriesId, forceUpdate})) { _logger.LogInformation("A duplicate request to refresh metadata for library occured. Skipping"); return; @@ -244,7 +247,7 @@ public class TaskScheduler : ITaskScheduler _logger.LogInformation("A duplicate request to scan series occured. Skipping"); return; } - if (RunningAnyTasksByMethod(new List() {ScannerService.Name, "ScanLibrary", "ScanLibraries", "ScanFolder", "ScanSeries"}, ScanQueue)) + if (RunningAnyTasksByMethod(ScanTasks, ScanQueue)) { _logger.LogInformation("A Scan is already running, rescheduling ScanSeries in 10 minutes"); BackgroundJob.Schedule(() => ScanSeries(libraryId, seriesId, forceUpdate), TimeSpan.FromMinutes(10)); @@ -282,6 +285,13 @@ public class TaskScheduler : ITaskScheduler await _versionUpdaterService.PushUpdate(update); } + public static bool HasScanTaskRunningForLibrary(int libraryId) + { + return + HasAlreadyEnqueuedTask("ScannerService", "ScanLibrary", new object[] {libraryId, true}, ScanQueue) || + HasAlreadyEnqueuedTask("ScannerService", "ScanLibrary", new object[] {libraryId, false}, ScanQueue); + } + /// /// Checks if this same invocation is already enqueued /// diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index 4420adedb..c33459681 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -20,6 +20,7 @@ namespace API.Services.Tasks Task DeleteChapterCoverImages(); Task DeleteTagCoverImages(); Task CleanupBackups(); + void CleanupTemp(); } /// /// Cleans up after operations on reoccurring basis @@ -127,16 +128,18 @@ namespace API.Services.Tasks } /// - /// Removes all files and directories in the cache directory + /// Removes all files and directories in the cache and temp directory /// public void CleanupCacheDirectory() { _logger.LogInformation("Performing cleanup of Cache directory"); _directoryService.ExistOrCreate(_directoryService.CacheDirectory); + _directoryService.ExistOrCreate(_directoryService.TempDirectory); try { _directoryService.ClearDirectory(_directoryService.CacheDirectory); + _directoryService.ClearDirectory(_directoryService.TempDirectory); } catch (Exception ex) { @@ -175,5 +178,22 @@ namespace API.Services.Tasks } _logger.LogInformation("Finished cleanup of Database backups at {Time}", DateTime.Now); } + + public void CleanupTemp() + { + _logger.LogInformation("Performing cleanup of Temp directory"); + _directoryService.ExistOrCreate(_directoryService.TempDirectory); + + try + { + _directoryService.ClearDirectory(_directoryService.TempDirectory); + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an issue deleting one or more folders/files during cleanup"); + } + + _logger.LogInformation("Temp directory purged"); + } } } diff --git a/API/Services/Tasks/Scanner/ParseScannedFiles.cs b/API/Services/Tasks/Scanner/ParseScannedFiles.cs index dab44e1ba..d31879e84 100644 --- a/API/Services/Tasks/Scanner/ParseScannedFiles.cs +++ b/API/Services/Tasks/Scanner/ParseScannedFiles.cs @@ -289,12 +289,19 @@ namespace API.Services.Tasks.Scanner await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", libraryName, ProgressEventType.Ended)); } + /// + /// Checks against all folder paths on file if the last scanned is >= the directory's last write down to the second + /// + /// + /// + /// + /// private bool HasSeriesFolderNotChangedSinceLastScan(IDictionary> seriesPaths, string normalizedFolder, bool forceCheck = false) { if (forceCheck) return false; - return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerMinute) >= - _directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerMinute)); + return seriesPaths.ContainsKey(normalizedFolder) && seriesPaths[normalizedFolder].All(f => f.LastScanned.Truncate(TimeSpan.TicksPerSecond) >= + _directoryService.GetLastWriteTime(normalizedFolder).Truncate(TimeSpan.TicksPerSecond)); } /// diff --git a/API/Services/Tasks/Scanner/ProcessSeries.cs b/API/Services/Tasks/Scanner/ProcessSeries.cs index 4319639b5..fb7da1ec4 100644 --- a/API/Services/Tasks/Scanner/ProcessSeries.cs +++ b/API/Services/Tasks/Scanner/ProcessSeries.cs @@ -375,6 +375,13 @@ public class ProcessSeries : IProcessSeries } } + var genres = chapters.SelectMany(c => c.Genres).ToList(); + GenreHelper.KeepOnlySameGenreBetweenLists(series.Metadata.Genres.ToList(), genres, genre => + { + if (series.Metadata.GenresLocked) return; + series.Metadata.Genres.Remove(genre); + }); + // NOTE: The issue here is that people is just from chapter, but series metadata might already have some people on it // I might be able to filter out people that are in locked fields? var people = chapters.SelectMany(c => c.People).ToList(); diff --git a/API/Startup.cs b/API/Startup.cs index 40987f874..8eab1221b 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -273,13 +273,14 @@ namespace API app.Use(async (context, next) => { - context.Response.GetTypedHeaders().CacheControl = - new Microsoft.Net.Http.Headers.CacheControlHeaderValue() - { - Public = false, - MaxAge = TimeSpan.FromSeconds(10), - }; - context.Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.Vary] = + // Note: I removed this as I caught Chrome caching api responses when it shouldn't have + // context.Response.GetTypedHeaders().CacheControl = + // new CacheControlHeaderValue() + // { + // Public = false, + // MaxAge = TimeSpan.FromSeconds(10), + // }; + context.Response.Headers[HeaderNames.Vary] = new[] { "Accept-Encoding" }; // Don't let the site be iframed outside the same origin (clickjacking) diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html index aefdfced6..0701e67f5 100644 --- a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html +++ b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.html @@ -12,7 +12,7 @@
- + {{item.name}} ({{libraryNames[item.libraryId]}}) diff --git a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts index 342a008f0..763976b8c 100644 --- a/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts +++ b/UI/Web/src/app/cards/edit-series-relation/edit-series-relation.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, OnInit, Output } from '@angular/core'; -import { UntypedFormControl } from '@angular/forms'; +import { FormControl } from '@angular/forms'; import { map, Subject, Observable, of, firstValueFrom, takeUntil, ReplaySubject } from 'rxjs'; import { UtilityService } from 'src/app/shared/_services/utility.service'; import { TypeaheadSettings } from 'src/app/typeahead/typeahead-settings'; @@ -13,7 +13,7 @@ import { SeriesService } from 'src/app/_services/series.service'; interface RelationControl { series: {id: number, name: string} | undefined; // Will add type as well typeaheadSettings: TypeaheadSettings; - formControl: UntypedFormControl; + formControl: FormControl; } @Component({ @@ -37,6 +37,8 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy { seriesSettings: TypeaheadSettings = new TypeaheadSettings(); libraryNames: {[key:number]: string} = {}; + focusTypeahead = new EventEmitter(); + get RelationKind() { return RelationKind; } @@ -79,9 +81,9 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy { } setupRelationRows(relations: Array, kind: RelationKind) { - relations.map(async item => { - const settings = await firstValueFrom(this.createSeriesTypeahead(item, kind)); - const form = new UntypedFormControl(kind, []); + relations.map(async (item, indx) => { + const settings = await firstValueFrom(this.createSeriesTypeahead(item, kind, indx)); + const form = new FormControl(kind, []); if (kind === RelationKind.Parent) { form.disable(); } @@ -93,15 +95,13 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy { } async addNewRelation() { - this.relations.push({series: undefined, formControl: new UntypedFormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation))}); + this.relations.push({series: undefined, formControl: new FormControl(RelationKind.Adaptation, []), typeaheadSettings: await firstValueFrom(this.createSeriesTypeahead(undefined, RelationKind.Adaptation, this.relations.length))}); this.cdRef.markForCheck(); // Focus on the new typeahead setTimeout(() => { - const typeahead = document.querySelector(`#relation--${this.relations.length - 1} .typeahead-input input`) as HTMLInputElement; - if (typeahead) typeahead.focus(); + this.focusTypeahead.emit(`relation--${this.relations.length - 1}`); }, 10); - } removeRelation(index: number) { @@ -120,11 +120,11 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy { this.cdRef.markForCheck(); } - createSeriesTypeahead(series: Series | undefined, relationship: RelationKind): Observable> { + createSeriesTypeahead(series: Series | undefined, relationship: RelationKind, index: number): Observable> { const seriesSettings = new TypeaheadSettings(); seriesSettings.minCharacters = 0; seriesSettings.multiple = false; - seriesSettings.id = 'format'; + seriesSettings.id = 'relation--' + index; seriesSettings.unique = true; seriesSettings.addIfNonExisting = false; seriesSettings.fetchFn = (searchFilter: string) => this.libraryService.search(searchFilter).pipe( @@ -165,7 +165,7 @@ export class EditSeriesRelationComponent implements OnInit, OnDestroy { const alternativeVersions = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeVersion && item.series !== undefined).map(item => item.series!.id); const doujinshis = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Doujinshi && item.series !== undefined).map(item => item.series!.id); - // TODO: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin + // NOTE: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis).subscribe(() => {}); } diff --git a/UI/Web/src/app/reading-list/reading-list-item/reading-list-item.component.ts b/UI/Web/src/app/reading-list/reading-list-item/reading-list-item.component.ts index bc4016d8a..4ba4f1e0c 100644 --- a/UI/Web/src/app/reading-list/reading-list-item/reading-list-item.component.ts +++ b/UI/Web/src/app/reading-list/reading-list-item/reading-list-item.component.ts @@ -42,7 +42,12 @@ export class ReadingListItemComponent implements OnInit { } if (item.seriesFormat === MangaFormat.EPUB) { - this.title = 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber); + const specialTitle = this.utilityService.cleanSpecialTitle(item.chapterNumber); + if (specialTitle === '0') { + this.title = 'Volume ' + this.utilityService.cleanSpecialTitle(item.volumeNumber); + } else { + this.title = 'Volume ' + specialTitle; + } } let chapterNum = item.chapterNumber; 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 bec4cb6b4..d0997103c 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -504,6 +504,9 @@ export class SeriesDetailComponent implements OnInit, OnDestroy, AfterContentChe if (this.relations.length > 0) { this.hasRelations = true; this.changeDetectionRef.markForCheck(); + } else { + this.hasRelations = false; + this.changeDetectionRef.markForCheck(); } }); diff --git a/UI/Web/src/app/typeahead/typeahead.component.ts b/UI/Web/src/app/typeahead/typeahead.component.ts index c680d35fe..ecae70e21 100644 --- a/UI/Web/src/app/typeahead/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/typeahead.component.ts @@ -168,6 +168,10 @@ export class TypeaheadComponent implements OnInit, OnDestroy { * If disabled, a user will not be able to interact with the typeahead */ @Input() disabled: boolean = false; + /** + * When triggered, will focus the input if the passed string matches the id + */ + @Input() focus: EventEmitter | undefined; @Output() selectedData = new EventEmitter(); @Output() newItemAdded = new EventEmitter(); @Output() onUnlock = new EventEmitter(); @@ -203,6 +207,13 @@ export class TypeaheadComponent implements OnInit, OnDestroy { this.init(); }); + if (this.focus) { + this.focus.pipe(takeUntil(this.onDestroy)).subscribe((id: string) => { + if (this.settings.id !== id) return; + this.onInputFocus(); + }); + } + this.init(); }