mirror of
https://github.com/Kareadita/Kavita.git
synced 2025-05-24 00:52:23 -04:00
(Kavita+) External Series Detail (#2309)
This commit is contained in:
parent
bd62e00ec5
commit
6067c9233c
@ -22,4 +22,5 @@ public static class EasyCacheProfiles
|
||||
public const string KavitaPlusReviews = "kavita+reviews";
|
||||
public const string KavitaPlusRecommendations = "kavita+recommendations";
|
||||
public const string KavitaPlusRatings = "kavita+ratings";
|
||||
public const string KavitaPlusExternalSeries = "kavita+externalSeries";
|
||||
}
|
||||
|
@ -1022,7 +1022,8 @@ public class OpdsController : BaseApiController
|
||||
/// <param name="pageNumber"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("{apiKey}/image")]
|
||||
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
|
||||
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId,
|
||||
[FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
|
||||
{
|
||||
var userId = await GetUser(apiKey);
|
||||
if (pageNumber < 0) return BadRequest(await _localizationService.Translate(userId, "greater-0", "Page"));
|
||||
|
@ -10,6 +10,7 @@ using API.DTOs.Dashboard;
|
||||
using API.DTOs.Filtering;
|
||||
using API.DTOs.Filtering.v2;
|
||||
using API.DTOs.Metadata;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.DTOs.SeriesDetail;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
@ -35,14 +36,17 @@ public class SeriesController : BaseApiController
|
||||
private readonly ISeriesService _seriesService;
|
||||
private readonly ILicenseService _licenseService;
|
||||
private readonly ILocalizationService _localizationService;
|
||||
private readonly IExternalMetadataService _externalMetadataService;
|
||||
private readonly IEasyCachingProvider _ratingCacheProvider;
|
||||
private readonly IEasyCachingProvider _reviewCacheProvider;
|
||||
private readonly IEasyCachingProvider _recommendationCacheProvider;
|
||||
private readonly IEasyCachingProvider _externalSeriesCacheProvider;
|
||||
public const string CacheKey = "recommendation_";
|
||||
|
||||
|
||||
public SeriesController(ILogger<SeriesController> logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork,
|
||||
ISeriesService seriesService, ILicenseService licenseService,
|
||||
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService)
|
||||
IEasyCachingProviderFactory cachingProviderFactory, ILocalizationService localizationService, IExternalMetadataService externalMetadataService)
|
||||
{
|
||||
_logger = logger;
|
||||
_taskScheduler = taskScheduler;
|
||||
@ -50,10 +54,12 @@ public class SeriesController : BaseApiController
|
||||
_seriesService = seriesService;
|
||||
_licenseService = licenseService;
|
||||
_localizationService = localizationService;
|
||||
_externalMetadataService = externalMetadataService;
|
||||
|
||||
_ratingCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRatings);
|
||||
_reviewCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusReviews);
|
||||
_recommendationCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusRecommendations);
|
||||
_externalSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -576,6 +582,32 @@ public class SeriesController : BaseApiController
|
||||
return BadRequest(await _localizationService.Translate(User.GetUserId(), "generic-relationship"));
|
||||
}
|
||||
|
||||
[Authorize(Policy = "RequireAdminRole")]
|
||||
[HttpGet("external-series-detail")]
|
||||
public async Task<ActionResult<ExternalSeriesDto>> GetExternalSeriesInfo(int? aniListId, long? malId)
|
||||
{
|
||||
if (!await _licenseService.HasActiveLicense())
|
||||
{
|
||||
return BadRequest();
|
||||
}
|
||||
|
||||
var cacheKey = $"{CacheKey}-{aniListId ?? 0}-{malId ?? 0}";
|
||||
var results = await _externalSeriesCacheProvider.GetAsync<ExternalSeriesDto>(cacheKey);
|
||||
if (results.HasValue)
|
||||
{
|
||||
return Ok(results.Value);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var ret = await _externalMetadataService.GetExternalSeriesDetail(aniListId, malId);
|
||||
await _externalSeriesCacheProvider.SetAsync(cacheKey, ret, TimeSpan.FromMinutes(15));
|
||||
return Ok(ret);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest("Unable to load External Series details");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
20
API/DTOs/Recommendation/ExternalSeriesDetailDto.cs
Normal file
20
API/DTOs/Recommendation/ExternalSeriesDetailDto.cs
Normal file
@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace API.DTOs.Recommendation;
|
||||
|
||||
public class ExternalSeriesDetailDto
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public int? AniListId { get; set; }
|
||||
public long? MALId { get; set; }
|
||||
public IList<string> Synonyms { get; set; }
|
||||
public PlusMediaFormat PlusMediaFormat { get; set; }
|
||||
public string? SiteUrl { get; set; }
|
||||
public string? CoverUrl { get; set; }
|
||||
public IList<string> Genres { get; set; }
|
||||
public IList<SeriesStaffDto> Staff { get; set; }
|
||||
public IList<MetadataTagDto> Tags { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int? VolumeCount { get; set; }
|
||||
public int? ChapterCount { get; set; }
|
||||
}
|
@ -7,4 +7,6 @@ public class ExternalSeriesDto
|
||||
public required string CoverUrl { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public string? Summary { get; set; }
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
}
|
||||
|
11
API/DTOs/Recommendation/MetadataTagDto.cs
Normal file
11
API/DTOs/Recommendation/MetadataTagDto.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace API.DTOs.Recommendation;
|
||||
|
||||
public class MetadataTagDto
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; private set; }
|
||||
public int? Rank { get; private set; }
|
||||
public bool IsGeneralSpoiler { get; private set; }
|
||||
public bool IsMediaSpoiler { get; private set; }
|
||||
public bool IsAdult { get; private set; }
|
||||
}
|
15
API/DTOs/Recommendation/PlusMediaFormat.cs
Normal file
15
API/DTOs/Recommendation/PlusMediaFormat.cs
Normal file
@ -0,0 +1,15 @@
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace API.DTOs.Recommendation;
|
||||
|
||||
public enum PlusMediaFormat
|
||||
{
|
||||
[Description("Manga")]
|
||||
Manga = 1,
|
||||
[Description("Comic")]
|
||||
Comic = 2,
|
||||
[Description("LightNovel")]
|
||||
LightNovel = 3,
|
||||
[Description("Book")]
|
||||
Book = 4
|
||||
}
|
11
API/DTOs/Recommendation/SeriesStaffDto.cs
Normal file
11
API/DTOs/Recommendation/SeriesStaffDto.cs
Normal file
@ -0,0 +1,11 @@
|
||||
namespace API.DTOs.Recommendation;
|
||||
|
||||
public class SeriesStaffDto
|
||||
{
|
||||
public required string Name { get; set; }
|
||||
public required string Url { get; set; }
|
||||
public required string Role { get; set; }
|
||||
public string? ImageUrl { get; set; }
|
||||
public string? Gender { get; set; }
|
||||
public string? Description { get; set; }
|
||||
}
|
@ -14,7 +14,6 @@ using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace API.Extensions;
|
||||
|
||||
@ -73,6 +72,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<ILicenseService, LicenseService>();
|
||||
services.AddScoped<IReviewService, ReviewService>();
|
||||
services.AddScoped<IRatingService, RatingService>();
|
||||
services.AddScoped<IExternalMetadataService, ExternalMetadataService>();
|
||||
|
||||
services.AddSqLite();
|
||||
services.AddSignalR(opt => opt.EnableDetailedErrors = true);
|
||||
@ -89,6 +89,7 @@ public static class ApplicationServiceExtensions
|
||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusReviews);
|
||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusRecommendations);
|
||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusRatings);
|
||||
options.UseInMemory(EasyCacheProfiles.KavitaPlusExternalSeries);
|
||||
});
|
||||
|
||||
services.AddMemoryCache(options =>
|
||||
|
@ -550,8 +550,12 @@ public class BookService : IBookService
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there is a SortTitle
|
||||
|
||||
// If this is a single book and not a collection, set publication status to Completed
|
||||
if (string.IsNullOrEmpty(info.Volume) && Parser.ParseVolume(filePath).Equals(Parser.DefaultVolume))
|
||||
{
|
||||
info.Number = "1";
|
||||
info.Count = 1;
|
||||
}
|
||||
|
||||
// Include regular Writer as well, for cases where there is no special tag
|
||||
info.Writer = string.Join(",",
|
||||
|
74
API/Services/Plus/ExternalMetadataService.cs
Normal file
74
API/Services/Plus/ExternalMetadataService.cs
Normal file
@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using API.Data;
|
||||
using API.DTOs.Recommendation;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers.Builders;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
using Kavita.Common.Helpers;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace API.Services.Plus;
|
||||
|
||||
public interface IExternalMetadataService
|
||||
{
|
||||
Task<ExternalSeriesDetailDto> GetExternalSeriesDetail(int? aniListId, long? malId);
|
||||
}
|
||||
|
||||
public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<ExternalMetadataService> _logger;
|
||||
|
||||
public ExternalMetadataService(IUnitOfWork unitOfWork, ILogger<ExternalMetadataService> logger)
|
||||
{
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
|
||||
FlurlHttp.ConfigureClient(Configuration.KavitaPlusApiUrl, cli =>
|
||||
cli.Settings.HttpClientFactory = new UntrustedCertClientFactory());
|
||||
}
|
||||
|
||||
public async Task<ExternalSeriesDetailDto?> GetExternalSeriesDetail(int? aniListId, long? malId)
|
||||
{
|
||||
if (!aniListId.HasValue && !malId.HasValue)
|
||||
{
|
||||
throw new KavitaException("Unable to find valid information from url for External Load");
|
||||
}
|
||||
|
||||
var license = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey)).Value;
|
||||
return await GetSeriesDetail(license, aniListId, malId);
|
||||
|
||||
}
|
||||
|
||||
private async Task<ExternalSeriesDetailDto?> GetSeriesDetail(string license, int? anilistId, long? malId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await (Configuration.KavitaPlusApiUrl + "/api/metadata/series/detail")
|
||||
.WithHeader("Accept", "application/json")
|
||||
.WithHeader("User-Agent", "Kavita")
|
||||
.WithHeader("x-license-key", license)
|
||||
.WithHeader("x-installId", HashUtil.ServerToken())
|
||||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.PostJsonAsync(new
|
||||
{
|
||||
AnilistId = anilistId,
|
||||
MalId = malId,
|
||||
})
|
||||
.ReceiveJson<ExternalSeriesDetailDto>();
|
||||
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An error happened during the request to Kavita+ API");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -10,9 +10,7 @@ using API.DTOs.Scrobbling;
|
||||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
@ -111,7 +109,9 @@ public class RecommendationService : IRecommendationService
|
||||
Name = string.IsNullOrEmpty(rec.Name) ? rec.RecommendationNames.First() : rec.Name,
|
||||
Url = rec.SiteUrl,
|
||||
CoverUrl = rec.CoverUrl,
|
||||
Summary = rec.Summary
|
||||
Summary = rec.Summary,
|
||||
AniListId = rec.AniListId,
|
||||
MalId = rec.MalId
|
||||
});
|
||||
}
|
||||
|
||||
|
2419
UI/Web/package-lock.json
generated
2419
UI/Web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -13,16 +13,16 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^16.1.8",
|
||||
"@angular/cdk": "^16.1.7",
|
||||
"@angular/common": "^16.1.8",
|
||||
"@angular/compiler": "^16.1.8",
|
||||
"@angular/core": "^16.1.8",
|
||||
"@angular/forms": "^16.1.8",
|
||||
"@angular/localize": "^16.1.8",
|
||||
"@angular/platform-browser": "^16.1.8",
|
||||
"@angular/platform-browser-dynamic": "^16.1.8",
|
||||
"@angular/router": "^16.1.8",
|
||||
"@angular/animations": "^16.2.7",
|
||||
"@angular/cdk": "^16.2.6",
|
||||
"@angular/common": "^16.2.7",
|
||||
"@angular/compiler": "^16.2.7",
|
||||
"@angular/core": "^16.2.7",
|
||||
"@angular/forms": "^16.2.7",
|
||||
"@angular/localize": "^16.2.7",
|
||||
"@angular/platform-browser": "^16.2.7",
|
||||
"@angular/platform-browser-dynamic": "^16.2.7",
|
||||
"@angular/router": "^16.2.7",
|
||||
"@fortawesome/fontawesome-free": "^6.4.2",
|
||||
"@iharbeck/ngx-virtual-scroller": "^16.0.0",
|
||||
"@iplab/ngx-file-upload": "^16.0.2",
|
||||
@ -56,14 +56,14 @@
|
||||
"zone.js": "^0.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^16.1.8",
|
||||
"@angular-devkit/build-angular": "^16.2.4",
|
||||
"@angular-eslint/builder": "^16.1.0",
|
||||
"@angular-eslint/eslint-plugin": "^16.1.0",
|
||||
"@angular-eslint/eslint-plugin-template": "^16.1.0",
|
||||
"@angular-eslint/schematics": "^16.1.0",
|
||||
"@angular-eslint/template-parser": "^16.1.0",
|
||||
"@angular/cli": "^16.1.8",
|
||||
"@angular/compiler-cli": "^16.1.8",
|
||||
"@angular/cli": "^16.2.4",
|
||||
"@angular/compiler-cli": "^16.2.7",
|
||||
"@types/d3": "^7.4.0",
|
||||
"@types/node": "^20.4.8",
|
||||
"@typescript-eslint/eslint-plugin": "^6.3.0",
|
||||
|
@ -110,8 +110,6 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
if (error.message !== 'User is not authenticated' && error.message !== 'errors.user-not-auth') {
|
||||
console.error('500 error: ', error);
|
||||
}
|
||||
// This just throws duplicate errors for no reason
|
||||
//this.toast(error.message);
|
||||
}
|
||||
else {
|
||||
this.toast('errors.unknown-crit');
|
||||
@ -120,7 +118,6 @@ export class ErrorInterceptor implements HttpInterceptor {
|
||||
}
|
||||
|
||||
private handleAuthError(error: any) {
|
||||
|
||||
// Special hack for register url, to not care about auth
|
||||
if (location.href.includes('/registration/confirm-email?token=')) {
|
||||
return;
|
||||
|
@ -0,0 +1,41 @@
|
||||
export enum PlusMediaFormat {
|
||||
Manga = 1,
|
||||
Comic = 2,
|
||||
LightNovel = 3,
|
||||
Book = 4
|
||||
}
|
||||
|
||||
export interface SeriesStaff {
|
||||
name: string;
|
||||
url: string;
|
||||
role: string;
|
||||
imageUrl?: string;
|
||||
gender?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface MetadataTagDto {
|
||||
name: string;
|
||||
description: string;
|
||||
rank?: number;
|
||||
isGeneralSpoiler: boolean;
|
||||
isMediaSpoiler: boolean;
|
||||
isAdult: boolean;
|
||||
}
|
||||
|
||||
export interface ExternalSeriesDetail {
|
||||
name: string;
|
||||
aniListId?: number;
|
||||
malId?: number;
|
||||
synonyms: Array<string>;
|
||||
plusMediaFormat: PlusMediaFormat;
|
||||
siteUrl?: string;
|
||||
coverUrl?: string;
|
||||
genres: Array<string>;
|
||||
summary?: string;
|
||||
volumeCount?: number;
|
||||
chapterCount?: number;
|
||||
staff: Array<SeriesStaff>;
|
||||
tags: Array<MetadataTagDto>;
|
||||
|
||||
}
|
@ -3,4 +3,6 @@ export interface ExternalSeries {
|
||||
coverUrl: string;
|
||||
url: string;
|
||||
summary: string;
|
||||
aniListId?: number;
|
||||
malId?: number;
|
||||
}
|
||||
|
@ -3,7 +3,6 @@ import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { environment } from 'src/environments/environment';
|
||||
import { FilterUtilitiesService } from '../shared/_services/filter-utilities.service';
|
||||
import { UtilityService } from '../shared/_services/utility.service';
|
||||
import { Chapter } from '../_models/chapter';
|
||||
import { ChapterMetadata } from '../_models/metadata/chapter-metadata';
|
||||
@ -21,6 +20,7 @@ import { SeriesFilterV2 } from '../_models/metadata/v2/series-filter-v2';
|
||||
import {UserReview} from "../_single-module/review-card/user-review";
|
||||
import {Rating} from "../_models/rating";
|
||||
import {Recommendation} from "../_models/series-detail/recommendation";
|
||||
import {ExternalSeriesDetail} from "../_models/series-detail/external-series-detail";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@ -228,4 +228,8 @@ export class SeriesService {
|
||||
removeFromOnDeck(seriesId: number) {
|
||||
return this.httpClient.post(this.baseUrl + 'series/remove-from-on-deck?seriesId=' + seriesId, {});
|
||||
}
|
||||
|
||||
getExternalSeriesDetails(aniListId?: number, malId?: number) {
|
||||
return this.httpClient.get<ExternalSeriesDetail>(this.baseUrl + 'series/external-series-detail?aniListId=' + (aniListId || 0) + '&malId=' + (malId || 0));
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,119 @@
|
||||
<ng-container *transloco="let t">
|
||||
<div class="offcanvas-header">
|
||||
<h5 class="offcanvas-title">
|
||||
<ng-container *ngIf="CoverUrl as coverUrl">
|
||||
<app-image *ngIf="coverUrl" height="230px" width="160px" maxHeight="230px" objectFit="contain" [imageUrl]="coverUrl"></app-image>
|
||||
<div class="">
|
||||
{{name}}
|
||||
</div>
|
||||
</ng-container>
|
||||
</h5>
|
||||
<button type="button" class="btn-close text-reset" [attr.aria-label]="t('common.close')" (click)="close()"></button>
|
||||
</div>
|
||||
|
||||
<div class="offcanvas-body">
|
||||
<ng-container *ngIf="externalSeries; else localSeriesBody">
|
||||
<span *ngIf="(externalSeries.volumeCount || 0) > 0 || (externalSeries.chapterCount || 0) > 0" class="text-muted" style="font-size: 14px; color: lightgrey">{{t('series-preview-drawer.vols-and-chapters', {volCount: externalSeries.volumeCount, chpCount: externalSeries.chapterCount})}}</span>
|
||||
<app-read-more *ngIf="externalSeries.summary" [maxLength]="300" [text]="externalSeries.summary"></app-read-more>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="externalSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-tag-badge>
|
||||
{{item}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="externalSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-tag-badge>
|
||||
{{item.name}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="externalSeries.staff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<div class="card mb-3" style="max-width: 180px;">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4">
|
||||
<ng-container *ngIf="item.imageUrl && !item.imageUrl.endsWith('default.jpg'); else localPerson">
|
||||
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="item.imageUrl" classes="person-img"></app-image>
|
||||
</ng-container>
|
||||
<ng-template #localPerson>
|
||||
<i class="fa fa-user-circle align-self-center person-img" style="font-size: 28px;" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{item.name}}</h6>
|
||||
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #localSeriesBody>
|
||||
<ng-container *ngIf="localSeries">
|
||||
<span class="text-muted" style="font-size: 14px; color: lightgrey">{{localSeries.publicationStatus | publicationStatus}}</span>
|
||||
<app-read-more [maxLength]="300" [text]="localSeries.summary"></app-read-more>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="localSeries.genres" [libraryId]="0" [heading]="t('series-preview-drawer.genres-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-tag-badge>
|
||||
{{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="localSeries.tags" [libraryId]="0" [heading]="t('series-preview-drawer.tags-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<app-tag-badge>
|
||||
{{item.title}}
|
||||
</app-tag-badge>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<app-metadata-detail [tags]="localStaff" [libraryId]="0" [heading]="t('series-preview-drawer.staff-label')">
|
||||
<ng-template #itemTemplate let-item>
|
||||
<div class="card mb-3" style="max-width: 180px;">
|
||||
<div class="row g-0">
|
||||
<div class="col-md-4">
|
||||
<i class="fa fa-user-circle align-self-center" style="font-size: 28px; margin-top: 24px; margin-left: 24px" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card-body">
|
||||
<h6 class="card-title">{{item.name}}</h6>
|
||||
<p class="card-text" style="font-size: 14px"><small class="text-muted">{{item.role}}</small></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
</app-metadata-detail>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
|
||||
<app-loading [loading]="isLoading"></app-loading>
|
||||
|
||||
<a class="btn btn-primary col-12 " [href]="url" target="_blank" rel="noopener noreferrer">
|
||||
{{t('series-preview-drawer.view-series')}}
|
||||
</a>
|
||||
</div>
|
||||
</ng-container>
|
@ -0,0 +1,10 @@
|
||||
// You must add this on a component based drawer
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
::ng-deep .person-img {
|
||||
margin-top: 24px; margin-left: 24px;
|
||||
}
|
@ -0,0 +1,87 @@
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {NgbActiveOffcanvas} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {ExternalSeriesDetail, SeriesStaff} from "../../_models/series-detail/external-series-detail";
|
||||
import {SeriesService} from "../../_services/series.service";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {LoadingComponent} from "../../shared/loading/loading.component";
|
||||
import {SafeHtmlPipe} from "../../pipe/safe-html.pipe";
|
||||
import {A11yClickDirective} from "../../shared/a11y-click.directive";
|
||||
import {MetadataDetailComponent} from "../../series-detail/_components/metadata-detail/metadata-detail.component";
|
||||
import {PersonBadgeComponent} from "../../shared/person-badge/person-badge.component";
|
||||
import {TagBadgeComponent} from "../../shared/tag-badge/tag-badge.component";
|
||||
import {ImageService} from "../../_services/image.service";
|
||||
import {PublicationStatusPipe} from "../../pipe/publication-status.pipe";
|
||||
import {SeriesMetadata} from "../../_models/metadata/series-metadata";
|
||||
import {ReadMoreComponent} from "../../shared/read-more/read-more.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-series-preview-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, TranslocoDirective, ImageComponent, LoadingComponent, SafeHtmlPipe, A11yClickDirective, MetadataDetailComponent, PersonBadgeComponent, TagBadgeComponent, PublicationStatusPipe, ReadMoreComponent],
|
||||
templateUrl: './series-preview-drawer.component.html',
|
||||
styleUrls: ['./series-preview-drawer.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class SeriesPreviewDrawerComponent implements OnInit {
|
||||
@Input({required: true}) name!: string;
|
||||
@Input() aniListId?: number;
|
||||
@Input() malId?: number;
|
||||
@Input() seriesId?: number;
|
||||
@Input() libraryId: number = 0;
|
||||
@Input({required: true}) isExternalSeries: boolean = true;
|
||||
|
||||
isLoading: boolean = true;
|
||||
localStaff: Array<SeriesStaff> = [];
|
||||
externalSeries: ExternalSeriesDetail | undefined;
|
||||
localSeries: SeriesMetadata | undefined;
|
||||
url: string = '';
|
||||
|
||||
private readonly activeOffcanvas = inject(NgbActiveOffcanvas);
|
||||
private readonly seriesService = inject(SeriesService);
|
||||
private readonly imageService = inject(ImageService);
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
get CoverUrl() {
|
||||
if (this.isExternalSeries) {
|
||||
if (this.externalSeries) return this.externalSeries.coverUrl;
|
||||
return this.imageService.placeholderImage;
|
||||
}
|
||||
return this.imageService.getSeriesCoverImage(this.seriesId!);
|
||||
}
|
||||
|
||||
|
||||
ngOnInit() {
|
||||
if (this.isExternalSeries) {
|
||||
this.seriesService.getExternalSeriesDetails(this.aniListId, this.malId).subscribe(externalSeries => {
|
||||
this.externalSeries = externalSeries;
|
||||
this.isLoading = false;
|
||||
if (this.externalSeries.siteUrl) {
|
||||
this.url = this.externalSeries.siteUrl;
|
||||
}
|
||||
|
||||
console.log('External Series Detail: ', this.externalSeries);
|
||||
this.cdRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.seriesService.getMetadata(this.seriesId!).subscribe(data => {
|
||||
this.localSeries = data;
|
||||
this.isLoading = false;
|
||||
this.url = 'library/' + this.libraryId + '/series/' + this.seriesId;
|
||||
this.localStaff = data.writers.map(p => {
|
||||
return {name: p.name, role: 'Story & Art'} as SeriesStaff;
|
||||
});
|
||||
this.cdRef.markForCheck();
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
close() {
|
||||
this.activeOffcanvas.close();
|
||||
}
|
||||
}
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
ElementRef, inject,
|
||||
Input,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
@ -9,9 +9,11 @@ import {CommonModule} from '@angular/common';
|
||||
import {ExternalSeries} from "../../_models/series-detail/external-series";
|
||||
import {RouterLinkActive} from "@angular/router";
|
||||
import {ImageComponent} from "../../shared/image/image.component";
|
||||
import {NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {NgbActiveOffcanvas, NgbOffcanvas, NgbProgressbar, NgbTooltip} from "@ng-bootstrap/ng-bootstrap";
|
||||
import {ReactiveFormsModule} from "@angular/forms";
|
||||
import {TranslocoDirective} from "@ngneat/transloco";
|
||||
import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-drawer/series-preview-drawer.component";
|
||||
import {SeriesService} from "../../_services/series.service";
|
||||
|
||||
@Component({
|
||||
selector: 'app-external-series-card',
|
||||
@ -23,9 +25,23 @@ import {TranslocoDirective} from "@ngneat/transloco";
|
||||
})
|
||||
export class ExternalSeriesCardComponent {
|
||||
@Input({required: true}) data!: ExternalSeries;
|
||||
/**
|
||||
* When clicking on the series, instead of opening, opens a preview drawer
|
||||
*/
|
||||
@Input() previewOnClick: boolean = false;
|
||||
@ViewChild('link', {static: false}) link!: ElementRef<HTMLAnchorElement>;
|
||||
|
||||
private readonly offcanvasService = inject(NgbOffcanvas);
|
||||
|
||||
handleClick() {
|
||||
if (this.previewOnClick) {
|
||||
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
|
||||
ref.componentInstance.isExternalSeries = true;
|
||||
ref.componentInstance.aniListId = this.data.aniListId;
|
||||
ref.componentInstance.malId = this.data.malId;
|
||||
ref.componentInstance.name = this.data.name;
|
||||
return;
|
||||
}
|
||||
if (this.link) {
|
||||
this.link.nativeElement.click();
|
||||
}
|
||||
|
@ -9,7 +9,7 @@ import {
|
||||
Output
|
||||
} from '@angular/core';
|
||||
import {Router} from '@angular/router';
|
||||
import {NgbModal} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {NgbModal, NgbOffcanvas} from '@ng-bootstrap/ng-bootstrap';
|
||||
import {ToastrService} from 'ngx-toastr';
|
||||
import {Series} from 'src/app/_models/series';
|
||||
import {ImageService} from 'src/app/_services/image.service';
|
||||
@ -23,6 +23,7 @@ import {CardItemComponent} from "../card-item/card-item.component";
|
||||
import {RelationshipPipe} from "../../pipe/relationship.pipe";
|
||||
import {Device} from "../../_models/device/device";
|
||||
import {TranslocoService} from "@ngneat/transloco";
|
||||
import {SeriesPreviewDrawerComponent} from "../../_single-module/series-preview-drawer/series-preview-drawer.component";
|
||||
|
||||
function deepClone(obj: any): any {
|
||||
if (obj === null || typeof obj !== 'object') {
|
||||
@ -76,6 +77,10 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
* When a series card is shown on deck, a special actionable is added to the list
|
||||
*/
|
||||
@Input() isOnDeck: boolean = false;
|
||||
/**
|
||||
* Opens a drawer with a preview of the metadata for this series
|
||||
*/
|
||||
@Input() previewOnClick: boolean = false;
|
||||
|
||||
@Output() clicked = new EventEmitter<Series>();
|
||||
/**
|
||||
@ -92,6 +97,7 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
imageUrl: string = '';
|
||||
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
private readonly offcanvasService = inject(NgbOffcanvas);
|
||||
|
||||
constructor(private router: Router, private cdRef: ChangeDetectorRef,
|
||||
private seriesService: SeriesService, private toastr: ToastrService,
|
||||
@ -234,6 +240,14 @@ export class SeriesCardComponent implements OnInit, OnChanges {
|
||||
}
|
||||
|
||||
handleClick() {
|
||||
if (this.previewOnClick) {
|
||||
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
|
||||
ref.componentInstance.isExternalSeries = false;
|
||||
ref.componentInstance.seriesId = this.data.id;
|
||||
ref.componentInstance.libraryId = this.data.libraryId;
|
||||
ref.componentInstance.name = this.data.name;
|
||||
return;
|
||||
}
|
||||
this.clicked.emit(this.data);
|
||||
this.router.navigate(['library', this.libraryId, 'series', this.data?.id]);
|
||||
}
|
||||
|
@ -11,6 +11,8 @@
|
||||
<h6 class="subtitle-with-actionables text-break" title="Localized Name">{{series.localizedName}}</h6>
|
||||
</ng-container>
|
||||
|
||||
|
||||
|
||||
<ng-template #extrasDrawer let-offcanvas>
|
||||
<div style="margin-top: 56px">
|
||||
<div class="offcanvas-header">
|
||||
@ -312,10 +314,10 @@
|
||||
<div class="card-container row g-0" #container>
|
||||
<ng-container *ngFor="let item of scroll.viewPortItems; let idx = index; trackBy: trackBySeriesIdentify">
|
||||
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [libraryId]="item.libraryId"></app-series-card>
|
||||
<app-series-card class="col-auto mt-2 mb-2" [data]="item" [previewOnClick]="true" [libraryId]="item.libraryId"></app-series-card>
|
||||
</ng-container>
|
||||
<ng-template #externalRec>
|
||||
<app-external-series-card class="col-auto mt-2 mb-2" [data]="item"></app-external-series-card>
|
||||
<app-external-series-card class="col-auto mt-2 mb-2" [previewOnClick]="true" [data]="item"></app-external-series-card>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</div>
|
||||
@ -325,14 +327,18 @@
|
||||
<ng-container *ngIf="!item.hasOwnProperty('coverUrl'); else externalRec">
|
||||
<app-external-list-item [imageUrl]="imageService.getSeriesCoverImage(item.id)" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
|
||||
<span (click)="previewSeries(item, false); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a href="/library/{{item.libraryId}}/series/{{item.id}}">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
</ng-container>
|
||||
<ng-template #externalRec>
|
||||
<app-external-list-item [imageUrl]="item.coverUrl" imageWidth="130px" imageHeight="" [summary]="item.summary">
|
||||
<ng-container title>
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
<span (click)="previewSeries(item, true); $event.stopPropagation(); $event.preventDefault();">
|
||||
<a [href]="item.url" target="_blank" rel="noreferrer nofollow">{{item.name}}</a>
|
||||
</span>
|
||||
</ng-container>
|
||||
</app-external-list-item>
|
||||
</ng-template>
|
||||
|
@ -71,6 +71,10 @@ import { TagBadgeComponent } from '../../../shared/tag-badge/tag-badge.component
|
||||
import { SideNavCompanionBarComponent } from '../../../sidenav/_components/side-nav-companion-bar/side-nav-companion-bar.component';
|
||||
import {TranslocoDirective, TranslocoService} from "@ngneat/transloco";
|
||||
import {CardActionablesComponent} from "../../../_single-module/card-actionables/card-actionables.component";
|
||||
import {ExternalSeries} from "../../../_models/series-detail/external-series";
|
||||
import {
|
||||
SeriesPreviewDrawerComponent
|
||||
} from "../../../_single-module/series-preview-drawer/series-preview-drawer.component";
|
||||
|
||||
interface RelatedSeriesPair {
|
||||
series: Series;
|
||||
@ -810,5 +814,22 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
|
||||
protected readonly undefined = undefined;
|
||||
previewSeries(item: Series | ExternalSeries, isExternal: boolean) {
|
||||
const ref = this.offcanvasService.open(SeriesPreviewDrawerComponent, {position: 'end', panelClass: 'navbar-offset'});
|
||||
ref.componentInstance.isExternalSeries = isExternal;
|
||||
ref.componentInstance.name = item.name;
|
||||
|
||||
if (isExternal) {
|
||||
const external = item as ExternalSeries;
|
||||
ref.componentInstance.aniListId = external.aniListId;
|
||||
ref.componentInstance.malId = external.malId;
|
||||
} else {
|
||||
const local = item as Series;
|
||||
ref.componentInstance.seriesId = local.id;
|
||||
ref.componentInstance.libraryId = local.libraryId;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
<img #img class="lazyload img-placeholder" src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||
<img #img class="lazyload img-placeholder {{classes}}" src="data:image/gif;base64,R0lGODlhAQABAPAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||
[attr.data-src]="imageUrl"
|
||||
(error)="imageService.updateErroredImage($event)"
|
||||
aria-hidden="true"
|
||||
|
@ -64,6 +64,7 @@ export class ImageComponent implements OnChanges {
|
||||
* If the image component should respond to cover updates
|
||||
*/
|
||||
@Input() processEvents: boolean = true;
|
||||
@Input() classes: string = '';
|
||||
|
||||
@ViewChild('img', {static: true}) imgElem!: ElementRef<HTMLImageElement>;
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
@ -1,8 +1,15 @@
|
||||
<div class="tagbadge cursor clickable" *ngIf="person !== undefined">
|
||||
<div class="d-flex">
|
||||
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>
|
||||
<ng-container *ngIf="isStaff && staff.imageUrl && !staff.imageUrl.endsWith('default.jpg'); else localPerson">
|
||||
<app-image height="24px" width="24px" objectFit="contain" [imageUrl]="staff.imageUrl"></app-image>
|
||||
</ng-container>
|
||||
<ng-template #localPerson>
|
||||
<i class="fa fa-user-circle align-self-center me-2" aria-hidden="true"></i>
|
||||
</ng-template>
|
||||
<div class="flex-grow-1">
|
||||
<span class="mt-0 mb-0">{{person.name}}</span>
|
||||
<span class="mt-0 mb-0">
|
||||
{{person.name}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,18 +1,28 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import {ChangeDetectionStrategy, ChangeDetectorRef, Component, inject, Input, OnInit} from '@angular/core';
|
||||
import { Person } from '../../_models/metadata/person';
|
||||
import {CommonModule} from "@angular/common";
|
||||
import {SeriesStaff} from "../../_models/series-detail/external-series-detail";
|
||||
import {ImageComponent} from "../image/image.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-person-badge',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, ImageComponent],
|
||||
templateUrl: './person-badge.component.html',
|
||||
styleUrls: ['./person-badge.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class PersonBadgeComponent {
|
||||
export class PersonBadgeComponent implements OnInit {
|
||||
|
||||
@Input({required: true}) person!: Person;
|
||||
@Input({required: true}) person!: Person | SeriesStaff;
|
||||
@Input() isStaff = false;
|
||||
|
||||
constructor() { }
|
||||
private readonly cdRef = inject(ChangeDetectorRef);
|
||||
|
||||
staff!: SeriesStaff;
|
||||
|
||||
ngOnInit() {
|
||||
this.staff = this.person as SeriesStaff;
|
||||
this.cdRef.markForCheck();
|
||||
}
|
||||
}
|
||||
|
@ -1675,6 +1675,14 @@
|
||||
"count-header": "Count"
|
||||
},
|
||||
|
||||
"series-preview-drawer": {
|
||||
"staff-label": "Staff",
|
||||
"tags-label": "{{filter-field-pipe.tags}}",
|
||||
"genres-label": "{{filter-field-pipe.genres}}",
|
||||
"view-series": "View Series",
|
||||
"vols-and-chapters": "{{volCount}} Volumes / {{chpCount}} Chapters"
|
||||
},
|
||||
|
||||
"server-stats": {
|
||||
"total-series-label": "Total Series",
|
||||
"total-series-tooltip": "Total Series: {{count}}",
|
||||
|
@ -42,3 +42,8 @@ hr {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.navbar-offset {
|
||||
margin-top: 56px;
|
||||
}
|
||||
|
||||
|
||||
|
59
openapi.json
59
openapi.json
@ -7,7 +7,7 @@
|
||||
"name": "GPL-3.0",
|
||||
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
|
||||
},
|
||||
"version": "0.7.8.5"
|
||||
"version": "0.7.8.6"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
@ -9107,6 +9107,53 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Series/external-series-detail": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Series"
|
||||
],
|
||||
"parameters": [
|
||||
{
|
||||
"name": "aniListId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "malId",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"format": "int64"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalSeriesDto"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalSeriesDto"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ExternalSeriesDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/Server/clear-cache": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@ -13767,6 +13814,16 @@
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"nullable": true
|
||||
},
|
||||
"aniListId": {
|
||||
"type": "integer",
|
||||
"format": "int32",
|
||||
"nullable": true
|
||||
},
|
||||
"malId": {
|
||||
"type": "integer",
|
||||
"format": "int64",
|
||||
"nullable": true
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
Loading…
x
Reference in New Issue
Block a user