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);
}