Kavita+ Enhancements (#2616)

This commit is contained in:
Joe Milazzo 2024-01-17 17:45:39 -06:00 committed by GitHub
parent 625c56b265
commit dd44f55747
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
43 changed files with 1056 additions and 468 deletions

View file

@ -0,0 +1,9 @@
import {Recommendation} from "./recommendation";
import {UserReview} from "../../_single-module/review-card/user-review";
import {Rating} from "../rating";
export interface SeriesDetailPlus {
recommendations: Recommendation;
reviews: Array<UserReview>;
ratings: Array<Rating>;
}

View file

@ -237,6 +237,13 @@ export class ActionFactoryService {
requiresAdmin: true,
children: [],
},
{
action: Action.Delete,
title: 'delete',
callback: this.dummyCallback,
requiresAdmin: true,
children: [],
},
],
},
{

View file

@ -135,6 +135,26 @@ export class ActionService implements OnDestroy {
});
}
async deleteLibrary(library: Partial<Library>, callback?: LibraryActionCallback) {
if (!library.hasOwnProperty('id') || library.id === undefined) {
return;
}
if (!await this.confirmService.alert(translate('toasts.confirm-library-delete'))) {
if (callback) {
callback(library);
}
return;
}
this.libraryService.delete(library?.id).pipe(take(1)).subscribe((res: any) => {
this.toastr.info(translate('toasts.library-deleted', {name: library.name}));
if (callback) {
callback(library);
}
});
}
/**
* Mark a series as read; updates the series pagesRead
* @param series Series, must have id and name populated

View file

@ -16,6 +16,7 @@ import {SortField} from "../_models/metadata/series-filter";
import {FilterCombination} from "../_models/metadata/v2/filter-combination";
import {SeriesFilterV2} from "../_models/metadata/v2/series-filter-v2";
import {FilterStatement} from "../_models/metadata/v2/filter-statement";
import {SeriesDetailPlus} from "../_models/series-detail/series-detail-plus";
@Injectable({
providedIn: 'root'
@ -25,7 +26,11 @@ export class MetadataService {
baseUrl = environment.apiUrl;
private validLanguages: Array<Language> = [];
constructor(private httpClient: HttpClient, private router: Router) { }
constructor(private httpClient: HttpClient) { }
getSeriesMetadataFromPlus(seriesId: number) {
return this.httpClient.get<SeriesDetailPlus | null>(this.baseUrl + 'metadata/series-detail-plus?seriesId=' + seriesId);
}
getAllAgeRatings(libraries?: Array<number>) {
let method = 'metadata/age-ratings'

View file

@ -1,7 +1,7 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {CommonModule} from '@angular/common';
import {ScrobblingService} from "../../_services/scrobbling.service";
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ScrobbleEvent, ScrobbleEventType} from "../../_models/scrobbling/scrobble-event";
import {ScrobbleEventTypePipe} from "../scrobble-event-type.pipe";
@ -11,10 +11,11 @@ import {debounceTime, take} from "rxjs/operators";
import {PaginatedResult, Pagination} from "../../_models/pagination";
import {SortableHeader, SortEvent} from "../table/_directives/sortable-header.directive";
import {FormControl, FormGroup, ReactiveFormsModule} from "@angular/forms";
import {TranslocoModule} from "@ngneat/transloco";
import {translate, TranslocoModule} from "@ngneat/transloco";
import {DefaultValuePipe} from "../../_pipes/default-value.pipe";
import {TranslocoLocaleModule} from "@ngneat/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ToastrService} from "ngx-toastr";
@Component({
selector: 'app-user-scrobble-history',
@ -26,9 +27,11 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
})
export class UserScrobbleHistoryComponent implements OnInit {
private readonly scrobbleService = inject(ScrobblingService);
private readonly scrobblingService = inject(ScrobblingService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
private readonly toastr = inject(ToastrService);
protected readonly ScrobbleEventType = ScrobbleEventType;
pagination: Pagination | undefined;
events: Array<ScrobbleEvent> = [];
@ -36,11 +39,16 @@ export class UserScrobbleHistoryComponent implements OnInit {
'filter': new FormControl('', [])
});
get ScrobbleEventType() { return ScrobbleEventType; }
ngOnInit() {
this.loadPage({column: 'createdUtc', direction: 'desc'});
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
if (hasExpired) {
this.toastr.error(translate('toasts.anilist-token-expired'));
}
this.cdRef.markForCheck();
});
this.formGroup.get('filter')?.valueChanges.pipe(debounceTime(200), takeUntilDestroyed(this.destroyRef)).subscribe(query => {
this.loadPage();
})
@ -73,7 +81,7 @@ export class UserScrobbleHistoryComponent implements OnInit {
const field = this.mapSortColumnField(sortEvent?.column);
const query = this.formGroup.get('filter')?.value;
this.scrobbleService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
this.scrobblingService.getScrobbleEvents({query, field, isDescending}, page, pageSize)
.pipe(take(1))
.subscribe((result: PaginatedResult<ScrobbleEvent[]>) => {
this.events = result.result;

View file

@ -14,8 +14,6 @@ export interface DirectoryPickerResult {
folderPath: string;
}
@Component({
selector: 'app-directory-picker',
templateUrl: './directory-picker.component.html',

View file

@ -24,6 +24,7 @@ import { RouterLink } from '@angular/router';
import { NgFor, NgIf } from '@angular/common';
import {translate, TranslocoModule} from "@ngneat/transloco";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {ActionService} from "../../_services/action.service";
@Component({
selector: 'app-manage-library',
@ -35,6 +36,15 @@ import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
})
export class ManageLibraryComponent implements OnInit {
private readonly actionService = inject(ActionService);
private readonly libraryService = inject(LibraryService);
private readonly modalService = inject(NgbModal);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
private readonly hubService = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly destroyRef = inject(DestroyRef);
libraries: Library[] = [];
loading = false;
/**
@ -42,11 +52,8 @@ export class ManageLibraryComponent implements OnInit {
*/
deletionInProgress: boolean = false;
libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`;
private readonly destroyRef = inject(DestroyRef);
constructor(private modalService: NgbModal, private libraryService: LibraryService,
private toastr: ToastrService, private confirmService: ConfirmService,
private hubService: MessageHubService, private readonly cdRef: ChangeDetectorRef) { }
ngOnInit(): void {
this.getLibraries();

View file

@ -19,7 +19,8 @@
<app-card-item [entity]="item" [title]="item.name" [imageUrl]="imageService.getSeriesCoverImage(item.id)"
[suppressArchiveWarning]="true" (clicked)="viewBookmarks(item)" [count]="seriesIds[item.id]" [allowSelection]="true"
[actions]="actions"
[selected]="bulkSelectionService.isCardSelected('bookmark', position)" (selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
[selected]="bulkSelectionService.isCardSelected('bookmark', position)"
(selection)="bulkSelectionService.handleCardSelection('bookmark', position, series.length, $event)"
></app-card-item>
</ng-template>

View file

@ -9,11 +9,11 @@ import {
} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ToastrService } from 'ngx-toastr';
import { take } from 'rxjs';
import {take} from 'rxjs';
import { BulkSelectionService } from 'src/app/cards/bulk-selection.service';
import { FilterSettings } from 'src/app/metadata-filter/filter-settings';
import { ConfirmService } from 'src/app/shared/confirm.service';
import { DownloadService } from 'src/app/shared/_services/download.service';
import {DownloadService} from 'src/app/shared/_services/download.service';
import { FilterUtilitiesService } from 'src/app/shared/_services/filter-utilities.service';
import { KEY_CODES } from 'src/app/shared/_services/utility.service';
import { JumpKey } from 'src/app/_models/jumpbar/jump-key';
@ -25,7 +25,6 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti
import { ImageService } from 'src/app/_services/image.service';
import { JumpbarService } from 'src/app/_services/jumpbar.service';
import { ReaderService } from 'src/app/_services/reader.service';
import { SeriesService } from 'src/app/_services/series.service';
import {DecimalPipe, NgIf} from '@angular/common';
import { CardItemComponent } from '../../../cards/card-item/card-item.component';
import { CardDetailLayoutComponent } from '../../../cards/card-detail-layout/card-detail-layout.component';
@ -45,11 +44,25 @@ import {Title} from "@angular/platform-browser";
})
export class BookmarksComponent implements OnInit {
private readonly translocoService = inject(TranslocoService);
private readonly readerService = inject(ReaderService);
private readonly downloadService = inject(DownloadService);
private readonly toastr = inject(ToastrService);
private readonly confirmService = inject(ConfirmService);
private readonly actionFactoryService = inject(ActionFactoryService);
private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
private readonly route = inject(ActivatedRoute);
private readonly jumpbarService = inject(JumpbarService);
private readonly titleService = inject(Title);
public readonly bulkSelectionService = inject(BulkSelectionService);
public readonly imageService = inject(ImageService);
bookmarks: Array<PageBookmark> = [];
series: Array<Series> = [];
loadingBookmarks: boolean = false;
seriesIds: {[id: number]: number} = {};
downloadingSeries: {[id: number]: boolean} = {};
clearingSeries: {[id: number]: boolean} = {};
actions: ActionItem<Series>[] = [];
jumpbarKeys: Array<JumpKey> = [];
@ -64,16 +77,7 @@ export class BookmarksComponent implements OnInit {
trackByIdentity = (index: number, item: Series) => `${item.name}_${item.localizedName}_${item.pagesRead}`;
refresh: EventEmitter<void> = new EventEmitter();
private readonly translocoService = inject(TranslocoService);
constructor(private readerService: ReaderService,
private downloadService: DownloadService, private toastr: ToastrService,
private confirmService: ConfirmService, public bulkSelectionService: BulkSelectionService,
public imageService: ImageService, private actionFactoryService: ActionFactoryService,
private router: Router, private readonly cdRef: ChangeDetectorRef,
private filterUtilityService: FilterUtilitiesService, private route: ActivatedRoute,
private jumpbarService: JumpbarService, private titleService: Title) {
constructor() {
this.filterUtilityService.filterPresetsFromUrl(this.route.snapshot).subscribe(filter => {
this.filter = filter;
@ -160,8 +164,11 @@ export class BookmarksComponent implements OnInit {
this.readerService.getAllBookmarks(this.filter).pipe(take(1)).subscribe(bookmarks => {
this.bookmarks = bookmarks;
this.bookmarks.forEach(bmk => {
this.downloadingSeries[bmk.seriesId] = false;
this.clearingSeries[bmk.seriesId] = false;
if (!this.seriesIds.hasOwnProperty(bmk.seriesId)) {
this.seriesIds[bmk.seriesId] = 0;
}
this.seriesIds[bmk.seriesId] += 1;
});
const distinctSeriesMap = new Map();
@ -199,14 +206,7 @@ export class BookmarksComponent implements OnInit {
}
downloadBookmarks(series: Series) {
this.downloadingSeries[series.id] = true;
this.cdRef.markForCheck();
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id), (d) => {
if (!d) {
this.downloadingSeries[series.id] = false;
this.cdRef.markForCheck();
}
});
this.downloadService.download('bookmark', this.bookmarks.filter(bmk => bmk.seriesId === series.id));
}
updateFilter(data: FilterEvent) {

View file

@ -108,45 +108,52 @@
<li [ngbNavItem]="tabs[TabID.Files]" [disabled]="(isAdmin$ | async) === false">
<a ngbNavLink>{{t(tabs[TabID.Files].title)}}</a>
<ng-template ngbNavContent>
<h4 *ngIf="!utilityService.isChapter(data)">{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
@if (!utilityService.isChapter(data)) {
<h4>{{utilityService.formatChapterName(libraryType) + 's'}}</h4>
}
<ul class="list-unstyled">
<li class="d-flex my-4" *ngFor="let chapter of chapters">
<!-- TODO: Localize title -->
<a (click)="readChapter(chapter)" href="javascript:void(0);" title="Read {{utilityService.formatChapterName(libraryType, true, false)}} {{formatChapterNumber(chapter)}}">
<app-image class="me-2" width="74px" [imageUrl]="imageService.getChapterCoverImage(chapter.id)"></app-image>
</a>
<div class="flex-grow-1">
<h5 class="mt-0 mb-1">
<span>
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
</span>
</span>
<span>
<span>
<app-card-actionables (actionHandler)="performAction($event, chapter)" [actions]="chapterActions"
[labelBy]="utilityService.formatChapterName(libraryType, true, true) + formatChapterNumber(chapter)"></app-card-actionables>
<ng-container *ngIf="chapter.number !== '0'; else specialHeader">
{{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}}
</ng-container>
</span>
<span class="badge bg-primary rounded-pill ms-1">
<span *ngIf="chapter.pagesRead > 0 && chapter.pagesRead < chapter.pages">{{chapter.pagesRead}} / {{chapter.pages}}</span>
<span *ngIf="chapter.pagesRead === 0">{{t('unread') | uppercase}}</span>
<span *ngIf="chapter.pagesRead === chapter.pages">{{t('read') | uppercase}}</span>
</span>
</span>
<ng-template #specialHeader>{{t('files')}}</ng-template>
</h5>
<ul class="list-group">
<li *ngFor="let file of chapter.files" class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
{{t('pages')}} {{file.pages | number:''}}
@for (file of chapter.files; track file.id) {
<li class="list-group-item no-hover">
<span>{{file.filePath}}</span>
<div class="row g-0">
<div class="col">
{{t('pages')}} {{file.pages | number:''}}
</div>
@if (data.hasOwnProperty('created')) {
<div class="col">
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
</div>
}
<div class="col">
{{t('size')}} {{file.bytes | bytes}}
</div>
</div>
<div class="col" *ngIf="data.hasOwnProperty('created')">
{{t('added')}} {{file.created | date: 'short' | defaultDate}}
</div>
<div class="col">
{{t('size')}} {{file.bytes | bytes}}
</div>
</div>
</li>
</li>
}
</ul>
</div>
</li>

View file

@ -68,12 +68,17 @@ enum TabID {
})
export class CardDetailDrawerComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);
protected readonly MangaFormat = MangaFormat;
protected readonly Breakpoint = Breakpoint;
protected readonly LibraryType = LibraryType;
protected readonly TabID = TabID;
@Input() parentName = '';
@Input() seriesId: number = 0;
@Input() libraryId: number = 0;
@Input({required: true}) data!: Volume | Chapter;
private readonly destroyRef = inject(DestroyRef);
/**
* If this is a volume, this will be first chapter for said volume.
@ -104,26 +109,13 @@ export class CardDetailDrawerComponent implements OnInit {
];
active = this.tabs[0];
chapterMetadata!: ChapterMetadata;
chapterMetadata: ChapterMetadata | undefined;
summary: string = '';
downloadInProgress: boolean = false;
get MangaFormat() {
return MangaFormat;
}
get Breakpoint() {
return Breakpoint;
}
get LibraryType() {
return LibraryType;
}
get TabID() {
return TabID;
}
constructor(public utilityService: UtilityService,
public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService,
@ -160,7 +152,7 @@ export class CardDetailDrawerComponent implements OnInit {
this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this))
.filter(item => item.action !== Action.Edit);
this.chapterActions.push({title: 'Read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
this.chapterActions.push({title: 'read', action: Action.Read, callback: this.handleChapterActionCallback.bind(this), requiresAdmin: false, children: []});
if (this.isChapter) {
const chapter = this.utilityService.asChapter(this.data);
this.chapterActions = this.actionFactoryService.filterSendToAction(this.chapterActions, chapter);

View file

@ -277,26 +277,9 @@ 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.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;
}
return null;
console.log('Card Item download obv called for entity: ', this.entity);
return this.downloadService.mapToEntityType(events, this.entity);
}));
}

View file

@ -1,86 +1,98 @@
<app-side-nav-companion-bar></app-side-nav-companion-bar>
<ng-container *transloco="let t; read: 'dashboard'">
<ng-container *ngIf="libraries$ | async as libraries">
<ng-container *ngIf="libraries.length === 0">
<div class="mt-3" *ngIf="isAdmin$ | async as isAdmin">
<div *ngIf="isAdmin" class="d-flex justify-content-center">
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a></p>
</div>
<div *ngIf="!isAdmin" class="d-flex justify-content-center">
<p>{{t('not-granted')}}</p>
</div>
</div>
</ng-container>
</ng-container>
@if (libraries$ | async; as libraries) {
@if (libraries.length === 0) {
@if (isAdmin$ | async; as isAdmin) {
<div class="mt-3">
@if (isAdmin) {
<div class="d-flex justify-content-center">
<p>{{t('no-libraries')}} <a routerLink="/admin/dashboard" fragment="libraries">{{t('server-settings-link')}}</a></p>
</div>
} @else {
<div class="d-flex justify-content-center">
<p>{{t('not-granted')}}</p>
</div>
}
</div>
}
}
}
<ng-container *ngFor="let stream of streams">
<ng-container [ngSwitch]="stream.streamType">
<ng-container *ngSwitchCase="StreamType.OnDeck" [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
@for(stream of streams; track stream.id) {
@switch (stream.streamType) {
@case (StreamType.OnDeck) {
<ng-container [ngTemplateOutlet]="onDeck" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
}
@case (StreamType.RecentlyUpdated) {
<ng-container [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
}
@case (StreamType.NewlyAdded) {
<ng-container [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
}
@case (StreamType.SmartFilter) {
<ng-container [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
}
@case (StreamType.MoreInGenre) {
<ng-container [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
}
}
<ng-container *ngSwitchCase="StreamType.RecentlyUpdated" [ngTemplateOutlet]="recentlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.NewlyAdded" [ngTemplateOutlet]="newlyUpdated" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.SmartFilter" [ngTemplateOutlet]="smartFilter" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
<ng-container *ngSwitchCase="StreamType.MoreInGenre" [ngTemplateOutlet]="moreInGenre" [ngTemplateOutletContext]="{ stream: stream }"></ng-container>
</ng-container>
<ng-template #smartFilter let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
@if(stream.api | async; as data) {
<app-carousel-reel [items]="data" [title]="stream.name" (sectionClick)="handleFilterSectionClick(stream)">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId"
(reload)="reloadStream(item.id)" (dataChanged)="reloadStream(item.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
}
</ng-template>
<ng-template #onDeck let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick('on deck')">
@if(stream.api | async; as data) {
<app-carousel-reel [items]="data" [title]="t('on-deck-title')" (sectionClick)="handleSectionClick(StreamId.OnDeck)">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" [isOnDeck]="true"
(reload)="reloadStream(stream.id)" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
}
</ng-template>
<ng-template #recentlyUpdated let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick('recently updated series')">
@if(stream.api | async; as data) {
<app-carousel-reel [items]="data" [title]="t('recently-updated-title')" (sectionClick)="handleSectionClick(StreamId.RecentlyUpdatedSeries)">
<ng-template #carouselItem let-item>
<app-card-item [entity]="item" [title]="item.seriesName" [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
[suppressArchiveWarning]="true" (clicked)="handleRecentlyAddedChapterClick(item)" [count]="item.count"></app-card-item>
</ng-template>
</app-carousel-reel>
</ng-container>
}
</ng-template>
<ng-template #newlyUpdated let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick('newly added series')">
@if(stream.api | async; as data) {
<app-carousel-reel [items]="data" [title]="t('recently-added-title')" (sectionClick)="handleSectionClick(StreamId.NewlyAddedSeries)">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
}
</ng-template>
<ng-template #moreInGenre let-stream: DashboardStream>
<ng-container *ngIf="(stream.api | async) as data">
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick('more in genre')">
@if(stream.api | async; as data) {
<app-carousel-reel [items]="data" [title]="t('more-in-genre-title', {genre: genre?.title})" (sectionClick)="handleSectionClick(StreamId.MoreInGenre)">
<ng-template #carouselItem let-item>
<app-series-card [data]="item" [libraryId]="item.libraryId" (dataChanged)="reloadStream(stream.id)"></app-series-card>
</ng-template>
</app-carousel-reel>
</ng-container>
}
</ng-template>
</ng-container>
}
<app-loading [loading]="isLoadingDashboard || (streamCount !== streamsLoaded)"></app-loading>
</ng-container>

View file

@ -1,4 +1,4 @@
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, Input, OnInit} from '@angular/core';
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, inject, OnInit} from '@angular/core';
import {Title} from '@angular/platform-browser';
import {Router, RouterLink} from '@angular/router';
import {Observable, of, ReplaySubject, Subject, switchMap} from 'rxjs';
@ -16,7 +16,7 @@ import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CardItemComponent} from '../../cards/card-item/card-item.component';
import {SeriesCardComponent} from '../../cards/series-card/series-card.component';
import {CarouselReelComponent} from '../../carousel/_components/carousel-reel/carousel-reel.component';
import {AsyncPipe, NgForOf, NgIf, NgSwitch, NgSwitchCase, NgTemplateOutlet} from '@angular/common';
import {AsyncPipe, NgForOf, NgTemplateOutlet} from '@angular/common';
import {
SideNavCompanionBarComponent
} from '../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
@ -30,6 +30,16 @@ import {Genre} from "../../_models/metadata/genre";
import {DashboardStream} from "../../_models/dashboard/dashboard-stream";
import {StreamType} from "../../_models/dashboard/stream-type.enum";
import {LoadingComponent} from "../../shared/loading/loading.component";
import {ScrobbleProvider, ScrobblingService} from "../../_services/scrobbling.service";
import {ToastrService} from "ngx-toastr";
enum StreamId {
OnDeck,
RecentlyUpdatedSeries,
NewlyAddedSeries,
MoreInGenre,
}
@Component({
selector: 'app-dashboard',
@ -37,8 +47,8 @@ import {LoadingComponent} from "../../shared/loading/loading.component";
styleUrls: ['./dashboard.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [SideNavCompanionBarComponent, NgIf, RouterLink, CarouselReelComponent, SeriesCardComponent,
CardItemComponent, AsyncPipe, TranslocoDirective, NgSwitchCase, NgSwitch, NgForOf, NgTemplateOutlet, LoadingComponent],
imports: [SideNavCompanionBarComponent, RouterLink, CarouselReelComponent, SeriesCardComponent,
CardItemComponent, AsyncPipe, TranslocoDirective, NgForOf, NgTemplateOutlet, LoadingComponent],
})
export class DashboardComponent implements OnInit {
@ -55,6 +65,8 @@ export class DashboardComponent implements OnInit {
private readonly messageHub = inject(MessageHubService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly dashboardService = inject(DashboardService);
private readonly scrobblingService = inject(ScrobblingService);
private readonly toastr = inject(ToastrService);
libraries$: Observable<Library[]> = this.libraryService.getLibraries().pipe(take(1), takeUntilDestroyed(this.destroyRef))
isLoadingDashboard = true;
@ -73,6 +85,7 @@ export class DashboardComponent implements OnInit {
*/
private loadRecentlyAdded$: ReplaySubject<void> = new ReplaySubject<void>();
protected readonly StreamType = StreamType;
protected readonly StreamId = StreamId;
constructor() {
this.loadDashboard();
@ -105,6 +118,14 @@ export class DashboardComponent implements OnInit {
}
});
this.scrobblingService.hasTokenExpired(ScrobbleProvider.AniList).subscribe(hasExpired => {
if (hasExpired) {
this.toastr.error(translate('toasts.anilist-token-expired'));
}
this.cdRef.markForCheck();
});
this.isAdmin$ = this.accountService.currentUser$.pipe(
takeUntilDestroyed(this.destroyRef),
map(user => (user && this.accountService.hasAdminRole(user)) || false),
@ -186,18 +207,18 @@ export class DashboardComponent implements OnInit {
await this.router.navigateByUrl('all-series?' + stream.smartFilterEncoded);
}
handleSectionClick(sectionTitle: string) {
if (sectionTitle.toLowerCase() === 'recently updated series') {
handleSectionClick(streamId: StreamId) {
if (streamId === StreamId.RecentlyUpdatedSeries) {
const params: any = {};
params['page'] = 1;
params['title'] = 'Recently Updated';
params['title'] = translate('dashboard.recently-updated-title');
const filter = this.filterUtilityService.createSeriesV2Filter();
if (filter.sortOptions) {
filter.sortOptions.sortField = SortField.LastChapterAdded;
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
} else if (sectionTitle.toLowerCase() === 'on deck') {
} else if (streamId === StreamId.OnDeck) {
const params: any = {};
params['page'] = 1;
params['title'] = translate('dashboard.on-deck-title');
@ -210,7 +231,7 @@ export class DashboardComponent implements OnInit {
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
} else if (sectionTitle.toLowerCase() === 'newly added series') {
} else if (streamId === StreamId.NewlyAddedSeries) {
const params: any = {};
params['page'] = 1;
params['title'] = translate('dashboard.recently-added-title');
@ -220,10 +241,10 @@ export class DashboardComponent implements OnInit {
filter.sortOptions.isAscending = false;
}
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();
} else if (sectionTitle.toLowerCase() === 'more in genre') {
} else if (streamId === StreamId.MoreInGenre) {
const params: any = {};
params['page'] = 1;
params['title'] = translate('more-in-genre-title', {genre: this.genre?.title});
params['title'] = translate('dashboard.more-in-genre-title', {genre: this.genre?.title});
const filter = this.filterUtilityService.createSeriesV2Filter();
filter.statements.push({field: FilterField.Genres, value: this.genre?.id + '', comparison: FilterComparison.MustContains});
this.filterUtilityService.applyFilterWithParams(['all-series'], filter, params).subscribe();

View file

@ -132,6 +132,9 @@
</div>
</li>
</ng-container>
@if(activeDownloads.length > 1) {
<li class="list-group-item dark-menu-item">{{activeDownloads.length}} downloads in Queue</li>
}
</ng-container>

View file

@ -61,16 +61,15 @@ export class EventsWidgetComponent implements OnInit, OnDestroy {
activeEvents: number = 0;
debugMode: boolean = false;
debugMode: boolean = true;
protected readonly EVENTS = EVENTS;
get EVENTS() {
return EVENTS;
}
public readonly downloadService = inject(DownloadService);
constructor(public messageHub: MessageHubService, private modalService: NgbModal,
private accountService: AccountService, private confirmService: ConfirmService,
private readonly cdRef: ChangeDetectorRef, public downloadService: DownloadService) {
private readonly cdRef: ChangeDetectorRef) {
}
ngOnDestroy(): void {

View file

@ -1,7 +1,7 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Component, DestroyRef,
inject,
Input,
OnInit,
@ -20,6 +20,7 @@ import {NgxStarsModule} from "ngx-stars";
import {ThemeService} from "../../../_services/theme.service";
import {Breakpoint, UtilityService} from "../../../shared/_services/utility.service";
import {ImageComponent} from "../../../shared/image/image.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@Component({
selector: 'app-external-rating',
@ -31,28 +32,31 @@ import {ImageComponent} from "../../../shared/image/image.component";
encapsulation: ViewEncapsulation.None
})
export class ExternalRatingComponent implements OnInit {
@Input({required: true}) seriesId!: number;
@Input({required: true}) userRating!: number;
@Input({required: true}) hasUserRated!: boolean;
@Input({required: true}) libraryType!: LibraryType;
private readonly cdRef = inject(ChangeDetectorRef);
private readonly seriesService = inject(SeriesService);
private readonly accountService = inject(AccountService);
private readonly themeService = inject(ThemeService);
public readonly utilityService = inject(UtilityService);
public readonly destroyRef = inject(DestroyRef);
protected readonly Breakpoint = Breakpoint;
@Input({required: true}) seriesId!: number;
@Input({required: true}) userRating!: number;
@Input({required: true}) hasUserRated!: boolean;
@Input({required: true}) libraryType!: LibraryType;
ratings: Array<Rating> = [];
isLoading: boolean = false;
overallRating: number = -1;
starColor = this.themeService.getCssVariable('--rating-star-color');
ngOnInit() {
this.seriesService.getOverallRating(this.seriesId).subscribe(r => this.overallRating = r.averageScore);
this.accountService.hasValidLicense$.subscribe((res) => {
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((res) => {
if (!res) return;
this.isLoading = true;
this.cdRef.markForCheck();
@ -74,6 +78,4 @@ export class ExternalRatingComponent implements OnInit {
this.cdRef.markForCheck();
});
}
protected readonly Breakpoint = Breakpoint;
}

View file

@ -105,22 +105,32 @@
</div>
<div class="col-auto ms-2 d-none d-md-block" *ngIf="isAdmin || hasDownloadingRole">
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="downloadInProgress">
<ng-container *ngIf="downloadInProgress; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">{{t('downloading-status')}}</span>
</ng-container>
<ng-template #notDownloading>
@if (download$ | async; as download) {
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')" [disabled]="download !== null">
<ng-container *ngIf="download !== null; else notDownloading">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
<span class="visually-hidden">{{t('downloading-status')}}</span>
</ng-container>
<ng-template #notDownloading>
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</ng-template>
</button>
} @else {
<button class="btn btn-secondary" (click)="downloadSeries()" [title]="t('download-series--tooltip')">
<i class="fa fa-arrow-alt-circle-down" aria-hidden="true"></i>
</ng-template>
</button>
</button>
}
</div>
</div>
<div *ngIf="seriesMetadata" class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
[libraryType]="libraryType"
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
</div>
@if (seriesMetadata) {
<div class="mt-2">
<app-series-metadata-detail [seriesMetadata]="seriesMetadata" [readingLists]="readingLists" [series]="series"
[libraryType]="libraryType"
[hasReadingProgress]="hasReadingProgress"></app-series-metadata-detail>
</div>
}
</div>
<div class="row" [ngClass]="{'pt-3': !seriesMetadata || seriesMetadata?.summary?.length === 0}">

View file

@ -1,4 +1,5 @@
import {
AsyncPipe,
DecimalPipe,
DOCUMENT,
NgClass,
@ -42,13 +43,13 @@ import {
NgbTooltip
} from '@ng-bootstrap/ng-bootstrap';
import {ToastrService} from 'ngx-toastr';
import {catchError, forkJoin, of} from 'rxjs';
import {take} from 'rxjs/operators';
import {catchError, forkJoin, Observable, of} from 'rxjs';
import {filter, map, take} from 'rxjs/operators';
import {BulkSelectionService} from 'src/app/cards/bulk-selection.service';
import {CardDetailDrawerComponent} from 'src/app/cards/card-detail-drawer/card-detail-drawer.component';
import {EditSeriesModalComponent} from 'src/app/cards/_modals/edit-series-modal/edit-series-modal.component';
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
import {DownloadService} from 'src/app/shared/_services/download.service';
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Chapter} from 'src/app/_models/chapter';
import {Device} from 'src/app/_models/device/device';
@ -105,6 +106,7 @@ import {PublicationStatus} from "../../../_models/metadata/publication-status";
import {NextExpectedChapter} from "../../../_models/series-detail/next-expected-chapter";
import {NextExpectedCardComponent} from "../../../cards/next-expected-card/next-expected-card.component";
import {ProviderImagePipe} from "../../../_pipes/provider-image.pipe";
import {MetadataService} from "../../../_services/metadata.service";
interface RelatedSeriesPair {
series: Series;
@ -126,19 +128,22 @@ interface StoryLineItem {
isChapter: boolean;
}
const KavitaPlusSupportedLibraryTypes = [LibraryType.Manga, LibraryType.Book];
@Component({
selector: 'app-series-detail',
templateUrl: './series-detail.component.html',
styleUrls: ['./series-detail.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe]
imports: [NgIf, SideNavCompanionBarComponent, CardActionablesComponent, ReactiveFormsModule, NgStyle, TagBadgeComponent, ImageComponent, NgbTooltip, NgbProgressbar, NgbDropdown, NgbDropdownToggle, NgbDropdownMenu, NgbDropdownItem, SeriesMetadataDetailComponent, CarouselReelComponent, ReviewCardComponent, BulkOperationsComponent, NgbNav, NgbNavItem, NgbNavLink, NgbNavContent, VirtualScrollerModule, NgFor, CardItemComponent, ListItemComponent, EntityTitleComponent, SeriesCardComponent, ExternalSeriesCardComponent, ExternalListItemComponent, NgbNavOutlet, LoadingComponent, DecimalPipe, TranslocoDirective, NgTemplateOutlet, NgSwitch, NgSwitchCase, NextExpectedCardComponent, NgClass, NgOptimizedImage, ProviderImagePipe, AsyncPipe]
})
export class SeriesDetailComponent implements OnInit, AfterContentChecked {
private readonly destroyRef = inject(DestroyRef);
private readonly route = inject(ActivatedRoute);
private readonly seriesService = inject(SeriesService);
private readonly metadataService = inject(MetadataService);
private readonly router = inject(Router);
private readonly modalService = inject(NgbModal);
private readonly toastr = inject(ToastrService);
@ -261,6 +266,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
user: User | undefined;
/**
* This is the download we get from download service.
*/
download$: Observable<DownloadEvent | null> | null = null;
bulkActionCallback = (action: ActionItem<any>, data: any) => {
if (this.series === undefined) {
return;
@ -368,6 +378,11 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
return;
}
// Setup the download in progress
this.download$ = this.downloadService.activeDownloads$.pipe(takeUntilDestroyed(this.destroyRef), map((events) => {
return this.downloadService.mapToEntityType(events, this.series);
}));
this.messageHub.messages$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(event => {
if (event.event === EVENTS.SeriesRemoved) {
const seriesRemovedEvent = event.payload as SeriesRemovedEvent;
@ -545,7 +560,14 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
if (![PublicationStatus.Ended, PublicationStatus.OnGoing].includes(this.seriesMetadata.publicationStatus)) return;
this.seriesService.getNextExpectedChapterDate(seriesId).subscribe(date => {
if (date == null || date.expectedDate === null) return;
if (date == null || date.expectedDate === null) {
if (this.nextExpectedChapter !== undefined) {
// Clear out the data so the card removes
this.nextExpectedChapter = undefined;
this.cdRef.markForCheck();
}
return;
}
this.nextExpectedChapter = date;
this.cdRef.markForCheck();
@ -563,6 +585,10 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
});
this.setContinuePoint();
if (KavitaPlusSupportedLibraryTypes.includes(this.libraryType) && loadExternal) {
this.loadPlusMetadata(this.seriesId);
}
forkJoin({
libType: this.libraryService.getLibraryType(this.libraryId),
series: this.seriesService.getSeries(seriesId)
@ -570,10 +596,6 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
this.libraryType = results.libType;
this.series = results.series;
if (this.libraryType !== LibraryType.Comic && loadExternal) {
this.loadReviews(true);
}
this.titleService.setTitle('Kavita - ' + this.series.name + ' Details');
this.seriesActions = this.actionFactoryService.getSeriesActions(this.handleSeriesActionCallback.bind(this))
@ -670,23 +692,37 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}
}
loadRecommendations() {
this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => {
rec.ownedSeries.map(r => {
// loadRecommendations() {
// this.seriesService.getRecommendationsForSeries(this.seriesId).subscribe(rec => {
// rec.ownedSeries.map(r => {
// this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary);
// });
// this.combinedRecs = [...rec.ownedSeries, ...rec.externalSeries];
// this.hasRecommendations = this.combinedRecs.length > 0;
// this.cdRef.markForCheck();
// });
// }
loadPlusMetadata(seriesId: number) {
this.metadataService.getSeriesMetadataFromPlus(seriesId).subscribe(data => {
if (data === null) return;
// Reviews
this.reviews = [...data.reviews];
// Recommendations
data.recommendations.ownedSeries.map(r => {
this.seriesService.getMetadata(r.id).subscribe(m => r.summary = m.summary);
});
this.combinedRecs = [...rec.ownedSeries, ...rec.externalSeries];
this.combinedRecs = [...data.recommendations.ownedSeries, ...data.recommendations.externalSeries];
this.hasRecommendations = this.combinedRecs.length > 0;
this.cdRef.markForCheck();
});
}
loadReviews(loadRecs: boolean = false) {
loadReviews() {
this.seriesService.getReviews(this.seriesId).subscribe(reviews => {
this.reviews = [...reviews];
if (loadRecs) {
this.loadRecommendations(); // We do this as first load will spam 3 calls on API layer
}
this.cdRef.markForCheck();
});
}
@ -829,7 +865,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
modalRef.componentInstance.series = this.series;
modalRef.closed.subscribe((closeResult: {success: boolean}) => {
if (closeResult.success) {
this.loadReviews();
this.loadReviews(); // TODO: Ensure reviews get updated here
}
});
}

View file

@ -4,7 +4,7 @@
</div>
<app-metadata-detail [tags]="['']" [libraryId]="series.libraryId" heading="Ratings">
<app-metadata-detail [tags]="['']" [libraryId]="series.libraryId" [heading]="t('rating-title')">
<ng-template #itemTemplate let-item>
<app-external-rating [seriesId]="series.id" [userRating]="series.userRating" [hasUserRated]="series.hasUserRated" [libraryType]="libraryType"></app-external-rating>
</ng-template>

View file

@ -48,6 +48,18 @@ import {ImageComponent} from "../../../shared/image/image.component";
})
export class SeriesMetadataDetailComponent implements OnChanges {
protected readonly imageService = inject(ImageService);
protected readonly utilityService = inject(UtilityService);
private readonly router = inject(Router);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
protected readonly FilterField = FilterField;
protected readonly LibraryType = LibraryType;
protected readonly MangaFormat = MangaFormat;
protected readonly TagBadgeCursor = TagBadgeCursor;
protected readonly Breakpoint = Breakpoint;
@Input({required: true}) seriesMetadata!: SeriesMetadata;
@Input({required: true}) libraryType!: LibraryType;
@Input() hasReadingProgress: boolean = false;
@ -60,23 +72,11 @@ export class SeriesMetadataDetailComponent implements OnChanges {
isCollapsed: boolean = true;
hasExtendedProperties: boolean = false;
protected readonly imageService = inject(ImageService);
protected readonly utilityService = inject(UtilityService);
private readonly router = inject(Router);
private readonly readerService = inject(ReaderService);
private readonly cdRef = inject(ChangeDetectorRef);
private readonly filterUtilityService = inject(FilterUtilitiesService);
/**
* Html representation of Series Summary
*/
seriesSummary: string = '';
protected FilterField = FilterField;
protected LibraryType = LibraryType;
protected MangaFormat = MangaFormat;
protected TagBadgeCursor = TagBadgeCursor;
get WebLinks() {
if (this.seriesMetadata?.webLinks === '') return [];
return this.seriesMetadata?.webLinks.split(',') || [];
@ -121,6 +121,4 @@ export class SeriesMetadataDetailComponent implements OnChanges {
navigate(basePage: string, id: number) {
this.router.navigate([basePage, id]);
}
protected readonly Breakpoint = Breakpoint;
}

View file

@ -12,7 +12,7 @@ import {
tap,
finalize,
of,
filter,
filter, Subject,
} from 'rxjs';
import { download, Download } from '../_models/download';
import { PageBookmark } from 'src/app/_models/readers/page-bookmark';
@ -22,6 +22,10 @@ import { BytesPipe } from 'src/app/_pipes/bytes.pipe';
import {translate} from "@ngneat/transloco";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {SAVER, Saver} from "../../_providers/saver.provider";
import {UtilityService} from "./utility.service";
import {CollectionTag} from "../../_models/collection-tag";
import {RecentlyAddedItem} from "../../_models/recently-added-item";
import {NextExpectedChapter} from "../../_models/series-detail/next-expected-chapter";
export const DEBOUNCE_TIME = 100;
@ -55,6 +59,7 @@ export type DownloadEntityType = 'volume' | 'chapter' | 'series' | 'bookmark' |
*/
export type DownloadEntity = Series | Volume | Chapter | PageBookmark[] | undefined;
export type QueueableDownloadType = Chapter | Volume;
@Injectable({
providedIn: 'root'
@ -68,14 +73,33 @@ export class DownloadService {
public SIZE_WARNING = 104_857_600;
private downloadsSource: BehaviorSubject<DownloadEvent[]> = new BehaviorSubject<DownloadEvent[]>([]);
/**
* Active Downloads
*/
public activeDownloads$ = this.downloadsSource.asObservable();
private downloadQueue: BehaviorSubject<QueueableDownloadType[]> = new BehaviorSubject<QueueableDownloadType[]>([]);
/**
* Queued Downloads
*/
public queuedDownloads$ = this.downloadQueue.asObservable();
private readonly destroyRef = inject(DestroyRef);
private readonly confirmService = inject(ConfirmService);
private readonly accountService = inject(AccountService);
private readonly httpClient = inject(HttpClient);
private readonly utilityService = inject(UtilityService);
constructor(@Inject(SAVER) private save: Saver) { }
constructor(@Inject(SAVER) private save: Saver) {
this.downloadQueue.subscribe((queue) => {
if (queue.length > 0) {
const entity = queue.shift();
console.log('Download Queue shifting entity: ', entity);
if (entity === undefined) return;
this.processDownload(entity);
}
});
}
/**
@ -84,7 +108,7 @@ export class DownloadService {
* @param downloadEntity
* @returns
*/
downloadSubtitle(downloadEntityType: DownloadEntityType, downloadEntity: DownloadEntity | undefined) {
downloadSubtitle(downloadEntityType: DownloadEntityType | undefined, downloadEntity: DownloadEntity | undefined) {
switch (downloadEntityType) {
case 'series':
return (downloadEntity as Series).name;
@ -97,6 +121,7 @@ export class DownloadService {
case 'logs':
return '';
}
return '';
}
/**
@ -117,10 +142,12 @@ export class DownloadService {
case 'volume':
sizeCheckCall = this.downloadVolumeSize((entity as Volume).id);
downloadCall = this.downloadVolume(entity as Volume);
//this.enqueueDownload(entity as Volume);
break;
case 'chapter':
sizeCheckCall = this.downloadChapterSize((entity as Chapter).id);
downloadCall = this.downloadChapter(entity as Chapter);
//this.enqueueDownload(entity as Chapter);
break;
case 'bookmark':
sizeCheckCall = of(0);
@ -145,8 +172,10 @@ export class DownloadService {
})
).pipe(filter(wantsToDownload => {
return wantsToDownload;
}), switchMap(() => {
return downloadCall.pipe(
}),
filter(_ => downloadCall !== undefined),
switchMap(() => {
return (downloadCall || of(undefined)).pipe(
tap((d) => {
if (callback) callback(d);
}),
@ -187,7 +216,40 @@ export class DownloadService {
);
}
private getIdKey(entity: Chapter | Volume) {
if (this.utilityService.isVolume(entity)) return 'volumeId';
if (this.utilityService.isChapter(entity)) return 'chapterId';
if (this.utilityService.isSeries(entity)) return 'seriesId';
return 'id';
}
private getDownloadEntityType(entity: Chapter | Volume): DownloadEntityType {
if (this.utilityService.isVolume(entity)) return 'volume';
if (this.utilityService.isChapter(entity)) return 'chapter';
if (this.utilityService.isSeries(entity)) return 'series';
return 'logs'; // This is a hack but it will never occur
}
private downloadEntity<T>(entity: Chapter | Volume): Observable<any> {
const downloadEntityType = this.getDownloadEntityType(entity);
const subtitle = this.downloadSubtitle(downloadEntityType, entity);
const idKey = this.getIdKey(entity);
const url = `${this.baseUrl}download/${downloadEntityType}?${idKey}=${entity.id}`;
return this.httpClient.get(url, { observe: 'events', responseType: 'blob', reportProgress: true }).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadEntityType, subtitle, entity.id)),
finalize(() => this.finalizeDownloadState(downloadEntityType, subtitle))
);
}
private downloadSeries(series: Series) {
// TODO: Call backend for all the volumes and loose leaf chapters then enqueque them all
const downloadType = 'series';
const subtitle = this.downloadSubtitle(downloadType, series);
return this.httpClient.get(this.baseUrl + 'download/series?seriesId=' + series.id,
@ -227,38 +289,42 @@ export class DownloadService {
}
private downloadChapter(chapter: Chapter) {
const downloadType = 'chapter';
const subtitle = this.downloadSubtitle(downloadType, chapter);
return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
return this.downloadEntity(chapter);
// const downloadType = 'chapter';
// const subtitle = this.downloadSubtitle(downloadType, chapter);
// return this.httpClient.get(this.baseUrl + 'download/chapter?chapterId=' + chapter.id,
// {observe: 'events', responseType: 'blob', reportProgress: true}
// ).pipe(
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
// download((blob, filename) => {
// this.save(blob, decodeURIComponent(filename));
// }),
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, chapter.id)),
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
// );
}
private downloadVolume(volume: Volume): Observable<Download> {
const downloadType = 'volume';
const subtitle = this.downloadSubtitle(downloadType, volume);
return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
{observe: 'events', responseType: 'blob', reportProgress: true}
).pipe(
throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
download((blob, filename) => {
this.save(blob, decodeURIComponent(filename));
}),
tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
private downloadVolume(volume: Volume) {
return this.downloadEntity(volume);
// const downloadType = 'volume';
// const subtitle = this.downloadSubtitle(downloadType, volume);
// return this.httpClient.get(this.baseUrl + 'download/volume?volumeId=' + volume.id,
// {observe: 'events', responseType: 'blob', reportProgress: true}
// ).pipe(
// throttleTime(DEBOUNCE_TIME, asyncScheduler, { leading: true, trailing: true }),
// download((blob, filename) => {
// this.save(blob, decodeURIComponent(filename));
// }),
// tap((d) => this.updateDownloadState(d, downloadType, subtitle, volume.id)),
// finalize(() => this.finalizeDownloadState(downloadType, subtitle))
// );
}
private async confirmSize(size: number, entityType: DownloadEntityType) {
return (size < this.SIZE_WARNING ||
await this.confirmService.confirm(translate('toasts.confirm-download-size', {entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})));
await this.confirmService.confirm(translate('toasts.confirm-download-size',
{entityType: translate('entity-type.' + entityType), size: bytesPipe.transform(size)})));
}
private downloadBookmarks(bookmarks: PageBookmark[]) {
@ -276,4 +342,60 @@ export class DownloadService {
finalize(() => this.finalizeDownloadState(downloadType, subtitle))
);
}
private processDownload(entity: QueueableDownloadType): void {
const downloadObservable = this.downloadEntity(entity);
console.log('Process Download called for entity: ', entity);
// When we consume one, we need to take it off the queue
downloadObservable.subscribe((downloadEvent) => {
// Download completed, process the next item in the queue
if (downloadEvent.state === 'DONE') {
this.processNextDownload();
}
});
}
private processNextDownload(): void {
const currentQueue = this.downloadQueue.value;
if (currentQueue.length > 0) {
const nextEntity = currentQueue[0];
this.processDownload(nextEntity);
}
}
private enqueueDownload(entity: QueueableDownloadType): void {
const currentQueue = this.downloadQueue.value;
const newQueue = [...currentQueue, entity];
this.downloadQueue.next(newQueue);
// If the queue was empty, start processing the download
if (currentQueue.length === 0) {
this.processNextDownload();
}
}
mapToEntityType(events: DownloadEvent[], entity: Series | Volume | Chapter | CollectionTag | PageBookmark | RecentlyAddedItem | NextExpectedChapter) {
if(this.utilityService.isSeries(entity)) {
return events.find(e => e.entityType === 'series' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('series', (entity as Series))) || null;
}
if(this.utilityService.isVolume(entity)) {
return events.find(e => e.entityType === 'volume' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('volume', (entity as Volume))) || null;
}
if(this.utilityService.isChapter(entity)) {
return events.find(e => e.entityType === 'chapter' && e.id == entity.id
&& e.subTitle === this.downloadSubtitle('chapter', (entity as Chapter))) || null;
}
// Is PageBookmark[]
if(entity.hasOwnProperty('length')) {
return events.find(e => e.entityType === 'bookmark'
&& e.subTitle === this.downloadSubtitle('bookmark', [(entity as PageBookmark)])) || null;
}
return null;
}
}

View file

@ -157,6 +157,9 @@ export class SideNavComponent implements OnInit {
case (Action.AnalyzeFiles):
await this.actionService.analyzeFiles(library);
break;
case (Action.Delete):
await this.actionService.deleteLibrary(library);
break;
case (Action.Edit):
this.actionService.editLibrary(library, () => window.scrollTo(0, 0));
break;

View file

@ -3,7 +3,18 @@
<div class="card-body">
<div class="card-title">
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-11"><h4 id="anilist-token-header">{{t('title')}}</h4></div>
<div class="col-10 col-sm-11">
<h4 id="anilist-token-header">{{t('title')}}
@if(!tokenExpired) {
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('token-valid')"></i>
<span class="visually-hidden">{{t('token-valid')}}</span>
} @else {
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('token-not-valid')"></i>
<span class="visually-hidden">{{t('token-not-valid')}}</span>
}
</h4>
</div>
<div class="col-1 text-end">
<button class="btn btn-primary btn-sm" [disabled]="!hasValidLicense" (click)="toggleViewMode()">{{isViewMode ? t('edit') : t('cancel')}}</button>
</div>

View file

@ -1,3 +1,9 @@
.error {
color: var(--error-color);
}
.confirm-icon {
color: var(--primary-color);
font-size: 14px;
vertical-align: middle;
}

View file

@ -5,10 +5,13 @@
<div class="container-fluid row mb-2">
<div class="col-10 col-sm-11">
<h4 id="email-card">{{t('email-label')}}
<ng-container *ngIf="!emailConfirmed">
<i class="fa-solid fa-circle ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-not-confirmed')"></i>
@if(emailConfirmed) {
<i class="fa-solid fa-circle-check ms-1 confirm-icon" aria-hidden="true" [ngbTooltip]="t('email-confirmed')"></i>
<span class="visually-hidden">{{t('email-confirmed')}}</span>
} @else {
<i class="fa-solid fa-circle ms-1 confirm-icon error" aria-hidden="true" [ngbTooltip]="t('email-not-confirmed')"></i>
<span class="visually-hidden">{{t('email-not-confirmed')}}</span>
</ng-container>
}
</h4>
</div>
<div class="col-1 text-end">

View file

@ -3,3 +3,7 @@
font-size: 14px;
vertical-align: middle;
}
.error {
color: var(--error-color);
}

View file

@ -245,6 +245,7 @@
"email-label": "{{common.email}}",
"current-password-label": "Current Password",
"email-not-confirmed": "This email is not confirmed",
"email-confirmed": "This email is confirmed",
"email-updated-title": "Email Updated",
"email-updated-description": "You can use the following link below to confirm the email for your account. If your server is externally accessible, an email will have been sent to the email and the link can be used to confirm the email.",
"setup-user-account": "Setup user's account",
@ -722,6 +723,7 @@
"series-metadata-detail": {
"links-title": "Links",
"rating-title": "Ratings",
"genres-title": "Genres",
"tags-title": "Tags",
"collections-title": "{{side-nav.collections}}",
@ -2000,7 +2002,8 @@
"confirm-delete-smart-filter": "Are you sure you want to delete this Smart Filter?",
"smart-filter-deleted": "Smart Filter Deleted",
"smart-filter-updated": "Created/Updated smart filter",
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key"
"external-source-already-exists": "An External Source already exists with the same Name/Host/API Key",
"anilist-token-expired": "Your AniList token is expired. Scrobbling will no longer process until you re-generate it in User Settings > Account"
},
"actionable": {

View file

@ -13,6 +13,9 @@
--primary-color-scrollbar: rgba(74,198,148,0.75);
--text-muted-color: lightgrey;
/* New Color scheme */
--secondary-color: #212328;
/* Meta and Globals */
--theme-color: #000000;
--color-scheme: dark;