mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-05-27 01:52:36 -04:00
CBL Export and External Metadata Ids (#4532)
This commit is contained in:
@@ -4,6 +4,8 @@ using System.Globalization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Hangfire;
|
||||
using Kavita.API.Services.Helpers;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.DTOs.Scrobbling;
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
@@ -127,92 +129,16 @@ public static class ScrobblingHelper
|
||||
|
||||
public static long? GetMalId(Series series)
|
||||
{
|
||||
var malId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite);
|
||||
return malId ?? series.ExternalSeriesMetadata?.MalId;
|
||||
return WeblinkParser.GetMalId(series.Metadata.WebLinks) ?? series.ExternalSeriesMetadata?.MalId;
|
||||
}
|
||||
|
||||
public static long? GetMalId(string weblinks)
|
||||
{
|
||||
return ExtractId<long?>(weblinks, MalWeblinkWebsite);
|
||||
}
|
||||
|
||||
public static int? GetAniListId(Series seriesWithExternalMetadata)
|
||||
{
|
||||
var aniListId = ExtractId<int?>(seriesWithExternalMetadata.Metadata.WebLinks, AniListWeblinkWebsite);
|
||||
var aniListId = WeblinkParser.GetAniListId(seriesWithExternalMetadata.Metadata.WebLinks);
|
||||
return aniListId ?? seriesWithExternalMetadata.ExternalSeriesMetadata?.AniListId;
|
||||
}
|
||||
|
||||
public static int? GetAniListId(string weblinks)
|
||||
{
|
||||
return ExtractId<int?>(weblinks, AniListWeblinkWebsite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extract an Id from a given weblink
|
||||
/// </summary>
|
||||
/// <param name="webLinks"></param>
|
||||
/// <param name="website"></param>
|
||||
/// <returns></returns>
|
||||
public static T? ExtractId<T>(string webLinks, string website)
|
||||
{
|
||||
var index = WeblinkExtractionMap[website];
|
||||
foreach (var webLink in webLinks.Split(','))
|
||||
{
|
||||
if (!webLink.StartsWith(website)) continue;
|
||||
|
||||
var tokens = webLink.Split(website)[1].Split('/');
|
||||
var value = tokens[index];
|
||||
|
||||
if (typeof(T) == typeof(int?))
|
||||
{
|
||||
if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue;
|
||||
|
||||
return default;
|
||||
}
|
||||
else if (typeof(T) == typeof(long?))
|
||||
{
|
||||
if (long.TryParse(value, CultureInfo.InvariantCulture, out var longValue)) return (T)(object)longValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(string))
|
||||
{
|
||||
return (T)(object)value;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generate a URL from a given ID and website
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the ID (e.g., int, long, string)</typeparam>
|
||||
/// <param name="id">The ID to embed in the URL</param>
|
||||
/// <param name="website">The base website URL</param>
|
||||
/// <returns>The generated URL or null if the website is not supported</returns>
|
||||
public static string? GenerateUrl<T>(T id, string website)
|
||||
{
|
||||
if (!WeblinkExtractionMap.ContainsKey(website))
|
||||
{
|
||||
return null; // Unsupported website
|
||||
}
|
||||
|
||||
if (Equals(id, default(T)))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(id), "ID cannot be null.");
|
||||
}
|
||||
|
||||
// Ensure the type of the ID matches supported types
|
||||
if (typeof(T) == typeof(int) || typeof(T) == typeof(long) || typeof(T) == typeof(string))
|
||||
{
|
||||
return $"{website}{id}";
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unsupported ID type. Supported types are int, long, and string.", nameof(id));
|
||||
}
|
||||
|
||||
public static string CreateUrl(string url, long? id)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Threading.Tasks;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
namespace Kavita.API.Services.ReadingLists;
|
||||
|
||||
|
||||
public interface ICblImportService
|
||||
{
|
||||
Task ValidateList(int userId, string filePath, CblImportOptions options);
|
||||
/// <summary>
|
||||
/// Creates a new RL or updates an existing
|
||||
/// </summary>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="options"></param>
|
||||
/// <returns></returns>
|
||||
Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions);
|
||||
/// <summary>
|
||||
/// Checks for updates against upstream ReadingList files and attempts to Update reading list.
|
||||
/// </summary>
|
||||
/// <remarks>Does not prompt for validation, makes best guess</remarks>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <returns></returns>
|
||||
Task SyncReadingList(int userId, int readingListId);
|
||||
}
|
||||
+3
-3
@@ -3,11 +3,12 @@ using System.Threading.Tasks;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.DTOs.ReadingLists;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.User;
|
||||
|
||||
namespace Kavita.API.Services.Reading;
|
||||
namespace Kavita.API.Services.ReadingLists;
|
||||
|
||||
public interface IReadingListService
|
||||
{
|
||||
@@ -19,8 +20,7 @@ public interface IReadingListService
|
||||
Task<AppUser?> UserHasReadingListAccess(int readingListId, string username);
|
||||
Task<bool> DeleteReadingList(int readingListId, AppUser user);
|
||||
Task CalculateReadingListAgeRating(ReadingList readingList);
|
||||
Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds,
|
||||
ReadingList readingList);
|
||||
Task<bool> AddChaptersToReadingList(int seriesId, IList<int> chapterIds, ReadingList readingList);
|
||||
|
||||
Task<CblImportSummaryDto> ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false);
|
||||
Task<CblImportSummaryDto> CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false);
|
||||
@@ -0,0 +1,29 @@
|
||||
using Kavita.Common.Helpers;
|
||||
|
||||
namespace Kavita.Common.Tests.Helpers;
|
||||
|
||||
public class WeblinkParserTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
|
||||
[InlineData("https://anilist.co/manga/30105", 30105)]
|
||||
[InlineData("https://anilist.co/manga/30105/Kekkaishi/", 30105)]
|
||||
public void CanParseWeblink_AniList(string link, int? expectedId)
|
||||
{
|
||||
Assert.Equal(WeblinkParser.GetAniListId(link), expectedId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://mangadex.org/title/316d3d09-bb83-49da-9d90-11dc7ce40967/honzuki-no-gekokujou-shisho-ni-naru-tame-ni-wa-shudan-wo-erandeiraremasen-dai-3-bu-ryouchi-ni-hon-o", "316d3d09-bb83-49da-9d90-11dc7ce40967")]
|
||||
public void CanParseWeblink_MangaDex(string link, string expectedId)
|
||||
{
|
||||
Assert.Equal(WeblinkParser.GetMangaDexId(link), expectedId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://comicvine.gamespot.com/chew-1-taster-s-choice-part-1-of-5/4000-159233/", "159233")]
|
||||
public void CanParseWeblink_ComicVine(string link, string expectedId)
|
||||
{
|
||||
Assert.Equal(WeblinkParser.GetComicVineId(link).Item1, expectedId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Kavita.Common.Helpers;
|
||||
#nullable enable
|
||||
|
||||
public static class WeblinkParser
|
||||
{
|
||||
private const string AniListWeblinkWebsite = "https://anilist.co/manga/";
|
||||
private const string MalWeblinkWebsite = "https://myanimelist.net/manga/";
|
||||
private const string MalStaffWebsite = "https://myanimelist.net/people/";
|
||||
private const string MalCharacterWebsite = "https://myanimelist.net/character/";
|
||||
private const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id=";
|
||||
private const string MangaDexWeblinkWebsite = "https://mangadex.org/title/";
|
||||
private const string AniListStaffWebsite = "https://anilist.co/staff/";
|
||||
private const string AniListCharacterWebsite = "https://anilist.co/character/";
|
||||
private const string HardcoverStaffWebsite = "https://hardcover.app/authors/";
|
||||
/// <summary>
|
||||
/// ComicVine has a unique structure:
|
||||
// https://comicvine.gamespot.com/batman-the-caped-crusader/4050-112794/
|
||||
// https://comicvine.gamespot.com/batman-the-caped-crusader-6-volume-6/4000-907546/
|
||||
// The 4050 implies this is a Series (TPB/Series) and 4000 implies single issue
|
||||
/// </summary>
|
||||
private const string ComicVineWeblinkWebsite = "https://comicvine.gamespot.com/";
|
||||
|
||||
private static readonly Dictionary<string, int> WeblinkExtractionMap = new()
|
||||
{
|
||||
{AniListWeblinkWebsite, 0},
|
||||
{MalWeblinkWebsite, 0},
|
||||
{GoogleBooksWeblinkWebsite, 0},
|
||||
{MangaDexWeblinkWebsite, 0},
|
||||
{AniListStaffWebsite, 0},
|
||||
{AniListCharacterWebsite, 0},
|
||||
{ComicVineWeblinkWebsite, 1},
|
||||
};
|
||||
|
||||
public static long? GetMalId(string? weblinks)
|
||||
{
|
||||
return ExtractId<long?>(weblinks, MalWeblinkWebsite);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to parse ComicVine Id from the weblinks. Returns id and true if Series/Volume Id.
|
||||
/// </summary>
|
||||
/// <param name="weblinks"></param>
|
||||
/// <returns></returns>
|
||||
public static Tuple<string?, bool> GetComicVineId(string? weblinks)
|
||||
{
|
||||
var extractedId = ExtractId<string?>(weblinks, ComicVineWeblinkWebsite);
|
||||
if (string.IsNullOrEmpty(extractedId)) return Tuple.Create<string?, bool>(null, false);
|
||||
return Tuple.Create<string?, bool>(extractedId.Split('-')[1], extractedId.StartsWith("4050"));
|
||||
}
|
||||
|
||||
public static int? GetAniListId(string? weblinks)
|
||||
{
|
||||
return ExtractId<int?>(weblinks, AniListWeblinkWebsite);
|
||||
}
|
||||
|
||||
public static int GetAniListCharacterId(string? url)
|
||||
{
|
||||
return ExtractId<int?>(url, AniListCharacterWebsite) ?? 0;
|
||||
}
|
||||
|
||||
public static int GetAniListStaffId(string? url)
|
||||
{
|
||||
return ExtractId<int?>(url, AniListStaffWebsite) ?? 0;
|
||||
}
|
||||
|
||||
public static string? GetGoogleBooksId(string? weblinks)
|
||||
{
|
||||
return ExtractId<string?>(weblinks, GoogleBooksWeblinkWebsite);
|
||||
}
|
||||
|
||||
public static string? GetMangaDexId(string? weblinks)
|
||||
{
|
||||
return ExtractId<string?>(weblinks, MangaDexWeblinkWebsite);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Extract an ID from a given weblink
|
||||
/// </summary>
|
||||
/// <param name="webLinks"></param>
|
||||
/// <param name="website"></param>
|
||||
/// <returns></returns>
|
||||
private static T? ExtractId<T>(string? webLinks, string website)
|
||||
{
|
||||
if (string.IsNullOrEmpty(webLinks)) return default;
|
||||
|
||||
var index = WeblinkExtractionMap[website];
|
||||
foreach (var webLink in webLinks.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (!webLink.StartsWith(website)) continue;
|
||||
|
||||
var tokens = webLink.Split(website)[1].Split('/');
|
||||
var value = tokens[index];
|
||||
|
||||
if (typeof(T) == typeof(int?))
|
||||
{
|
||||
if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value, CultureInfo.InvariantCulture, out var intValue)) return (T)(object)intValue;
|
||||
|
||||
return default;
|
||||
}
|
||||
else if (typeof(T) == typeof(long?))
|
||||
{
|
||||
if (long.TryParse(value, CultureInfo.InvariantCulture, out var longValue)) return (T)(object)longValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(string))
|
||||
{
|
||||
return (T)(object)value;
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Generate a URL from a given ID and website
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Type of the ID (e.g., int, long, string)</typeparam>
|
||||
/// <param name="id">The ID to embed in the URL</param>
|
||||
/// <param name="website">The base website URL</param>
|
||||
/// <returns>The generated URL or null if the website is not supported</returns>
|
||||
public static string? GenerateUrl<T>(T id, string website)
|
||||
{
|
||||
if (!WeblinkExtractionMap.ContainsKey(website))
|
||||
{
|
||||
return null; // Unsupported website
|
||||
}
|
||||
|
||||
if (Equals(id, default(T)))
|
||||
{
|
||||
throw new ArgumentNullException(nameof(id), "ID cannot be null.");
|
||||
}
|
||||
|
||||
// Ensure the type of the ID matches supported types
|
||||
if (typeof(T) == typeof(int) || typeof(T) == typeof(long) || typeof(T) == typeof(string))
|
||||
{
|
||||
return $"{website}{id}";
|
||||
}
|
||||
|
||||
throw new ArgumentException("Unsupported ID type. Supported types are int, long, and string.", nameof(id));
|
||||
}
|
||||
}
|
||||
+4535
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,213 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Kavita.Database.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ExternalMetadataIdsForEntities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AniListId",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ComicVineId",
|
||||
table: "Volume",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "HardcoverId",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MalId",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MangaBakaId",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MetronId",
|
||||
table: "Volume",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AniListId",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ComicVineId",
|
||||
table: "Series",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "HardcoverId",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MalId",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MangaBakaId",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MetronId",
|
||||
table: "Series",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "AniListId",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ComicVineId",
|
||||
table: "Chapter",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "HardcoverId",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MalId",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MangaBakaId",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
|
||||
migrationBuilder.AddColumn<long>(
|
||||
name: "MetronId",
|
||||
table: "Chapter",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0L);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AniListId",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ComicVineId",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HardcoverId",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MalId",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MangaBakaId",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MetronId",
|
||||
table: "Volume");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AniListId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ComicVineId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HardcoverId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MalId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MangaBakaId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MetronId",
|
||||
table: "Series");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AniListId",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ComicVineId",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "HardcoverId",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MalId",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MangaBakaId",
|
||||
table: "Chapter");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "MetronId",
|
||||
table: "Chapter");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ using System.Threading.Tasks;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services.Helpers;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.Common.Extensions;
|
||||
@@ -629,16 +630,15 @@ public class SeriesRepository(DataContext context, IMapper mapper) : ISeriesRepo
|
||||
AltSeriesName = series.LocalizedName,
|
||||
AniListId = series.ExternalSeriesMetadata.AniListId != 0
|
||||
? series.ExternalSeriesMetadata.AniListId
|
||||
: ScrobblingHelper.ExtractId<int?>(series.Metadata.WebLinks, ScrobblingHelper.AniListWeblinkWebsite),
|
||||
: WeblinkParser.GetAniListId(series.Metadata.WebLinks),
|
||||
MalId = series.ExternalSeriesMetadata.MalId != 0
|
||||
? series.ExternalSeriesMetadata.MalId
|
||||
: ScrobblingHelper.ExtractId<long?>(series.Metadata.WebLinks, ScrobblingHelper.MalWeblinkWebsite),
|
||||
: WeblinkParser.GetMalId(series.Metadata.WebLinks),
|
||||
CbrId = series.ExternalSeriesMetadata.CbrId,
|
||||
GoogleBooksId = !string.IsNullOrEmpty(series.ExternalSeriesMetadata.GoogleBooksId)
|
||||
? series.ExternalSeriesMetadata.GoogleBooksId
|
||||
: ScrobblingHelper.ExtractId<string?>(series.Metadata.WebLinks, ScrobblingHelper.GoogleBooksWeblinkWebsite),
|
||||
MangaDexId = ScrobblingHelper.ExtractId<string?>(series.Metadata.WebLinks,
|
||||
ScrobblingHelper.MangaDexWeblinkWebsite),
|
||||
: WeblinkParser.GetGoogleBooksId(series.Metadata.WebLinks),
|
||||
MangaDexId = WeblinkParser.GetMangaDexId(series.Metadata.WebLinks),
|
||||
VolumeCount = series.Volumes.Count,
|
||||
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
|
||||
Year = series.Metadata.ReleaseYear
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace Kavita.Models.DTOs;
|
||||
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
|
||||
/// file (abstracted from type).
|
||||
/// </summary>
|
||||
public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage, IHasMetadataIds
|
||||
{
|
||||
/// <inheritdoc cref="Chapter.Id"/>
|
||||
public int Id { get; init; }
|
||||
@@ -183,4 +183,13 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
PrimaryColor = string.Empty;
|
||||
SecondaryColor = string.Empty;
|
||||
}
|
||||
|
||||
#region Metadata
|
||||
public int AniListId { get; set; }
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace Kavita.Models.DTOs.Common;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// Provides a set of optional (non-API breaking) fields for updating external metadata ids
|
||||
/// </summary>
|
||||
public interface IUpdateExternalMetadataIds
|
||||
{
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
public long? MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long? MangaBakaId { get; set; }
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
|
||||
[XmlRoot(ElementName="Book")]
|
||||
public sealed record CblBook
|
||||
{
|
||||
[XmlAttribute("Series")]
|
||||
public string Series { get; set; }
|
||||
/// <summary>
|
||||
/// Chapter Number
|
||||
/// </summary>
|
||||
[XmlAttribute("Number")]
|
||||
public string Number { get; set; }
|
||||
/// <summary>
|
||||
/// Volume Number (usually for Comics they are the year)
|
||||
/// </summary>
|
||||
[XmlAttribute("Volume")]
|
||||
public string Volume { get; set; }
|
||||
[XmlAttribute("Year")]
|
||||
public string Year { get; set; }
|
||||
/// <summary>
|
||||
/// Main Series, Annual, Limited Series
|
||||
/// </summary>
|
||||
/// <remarks>This maps to <see cref="ComicInfo">Format</see> tag</remarks>
|
||||
[XmlAttribute("Format")]
|
||||
public string Format { get; set; }
|
||||
/// <summary>
|
||||
/// The underlying filetype
|
||||
/// </summary>
|
||||
/// <remarks>This is not part of the standard and explicitly for Kavita to support non cbz/cbr files</remarks>
|
||||
[XmlAttribute("FileType")]
|
||||
public string FileType { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// Known external comic database providers used for issue/series identification.
|
||||
/// </summary>
|
||||
public enum CblExternalDbProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Comic Vine (comicvine.gamespot.com). Provider short-name: "cv"
|
||||
/// </summary>
|
||||
ComicVine,
|
||||
/// <summary>
|
||||
/// Metron (metron.cloud). Provider short-name: "metron"
|
||||
/// </summary>
|
||||
Metron,
|
||||
/// <summary>
|
||||
/// Grand Comics Database (comics.org). Provider short-name: "gcd"
|
||||
/// </summary>
|
||||
GrandComicsDatabase,
|
||||
/// <summary>
|
||||
/// Unrecognised or missing provider
|
||||
/// </summary>
|
||||
Unknown
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// A resolved external-database reference for a series/issue pair.
|
||||
/// Populated from V1 <c>Database</c> elements or V2 <c>issueList[].id[]</c> entries.
|
||||
/// </summary>
|
||||
public sealed record CblExternalId
|
||||
{
|
||||
/// <summary>
|
||||
/// The external database provider (e.g. ComicVine, Metron).
|
||||
/// </summary>
|
||||
public CblExternalDbProvider Provider { get; set; }
|
||||
/// <summary>
|
||||
/// Provider-specific series identifier.
|
||||
/// </summary>
|
||||
public string SeriesId { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Provider-specific issue identifier.
|
||||
/// </summary>
|
||||
public string IssueId { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a set of decisions against ambiguity in CBL Import
|
||||
/// </summary>
|
||||
public record CblImportDecisions
|
||||
{
|
||||
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
public record CblImportOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Weighs ComicVine Matching higher
|
||||
/// </summary>
|
||||
public bool PreferComicVineMatching { get; set; }
|
||||
/// <summary>
|
||||
/// Libraries to search against. If empty, will include all
|
||||
/// </summary>
|
||||
public IList<int> ApplicableLibraries { get; set; }
|
||||
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// Categorisation of an issue's role within a reading list (V2 only).
|
||||
/// </summary>
|
||||
public enum CblIssueType
|
||||
{
|
||||
/// <summary>
|
||||
/// Unrecognised or unspecified issue type.
|
||||
/// </summary>
|
||||
Unknown,
|
||||
/// <summary>
|
||||
/// A core issue in an event storyline.
|
||||
/// </summary>
|
||||
EventCore,
|
||||
/// <summary>
|
||||
/// A tie-in issue that crosses over with an event.
|
||||
/// </summary>
|
||||
EventTieIn,
|
||||
/// <summary>
|
||||
/// A standalone one-shot related to an event.
|
||||
/// </summary>
|
||||
EventOneShot,
|
||||
/// <summary>
|
||||
/// A regular ongoing series issue.
|
||||
/// </summary>
|
||||
Ongoing
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// Classification of a CBL reading list, indicating its scope or purpose.
|
||||
/// </summary>
|
||||
public enum CblListType
|
||||
{
|
||||
/// <summary>
|
||||
/// Unrecognised or unspecified list type
|
||||
/// </summary>
|
||||
Unknown,
|
||||
/// <summary>
|
||||
/// A master reading order spanning an entire publisher's output
|
||||
/// </summary>
|
||||
Master,
|
||||
/// <summary>
|
||||
/// Crosses multiple fictional universes within a publisher
|
||||
/// </summary>
|
||||
Interuniversal,
|
||||
/// <summary>
|
||||
/// Scoped to a single fictional universe
|
||||
/// </summary>
|
||||
Universal,
|
||||
/// <summary>
|
||||
/// Focused on a specific super-hero team (e.g. Avengers, Justice League)
|
||||
/// </summary>
|
||||
Team,
|
||||
/// <summary>
|
||||
/// Focused on a single character (e.g. Spider-Man, Batman)
|
||||
/// </summary>
|
||||
Character,
|
||||
/// <summary>
|
||||
/// Follows a specific story arc or crossover event
|
||||
/// </summary>
|
||||
Story
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// A link to a related reading list (e.g. prequel, sequel, companion)
|
||||
/// Populated from V2 <c>listDetails.relationships[]</c>
|
||||
/// </summary>
|
||||
public sealed record CblRelationship
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name of the related reading list
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// UUID of the related reading list's CBL file
|
||||
/// </summary>
|
||||
public string Uuid { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Nature of the relationship (e.g. "prequel", "sequel", "companion")
|
||||
/// </summary>
|
||||
public string Relationship { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// An external source from which a reading list was derived
|
||||
/// Populated from V2 <c>listDetails.source[]</c>
|
||||
/// </summary>
|
||||
public sealed record CblSource
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the source (e.g. "Comic Book Herald")
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// URL pointing to the source material
|
||||
/// </summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// A single issue/book entry in a unified (V1+V2) parsed reading list
|
||||
/// </summary>
|
||||
public sealed record ParsedCblItem
|
||||
{
|
||||
/// <summary>
|
||||
/// Zero-based position of this item in the reading list
|
||||
/// </summary>
|
||||
public int Order { get; set; }
|
||||
/// <summary>
|
||||
/// Name of the comic series. Sourced from V1 <c>Book/@Series</c> or V2 <c>seriesName</c>
|
||||
/// </summary>
|
||||
public string SeriesName { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Issue/chapter number. Sourced from V1 <c>Book/@Number</c> or V2 <c>issueNumber</c>
|
||||
/// </summary>
|
||||
public string Number { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Volume identifier. V1: <c>Book/@Volume</c> (often the year). V2: derived from <c>seriesStartYear</c>
|
||||
/// </summary>
|
||||
public string Volume { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Publication year. V1: <c>Book/@Year</c>. V2: extracted from <c>issueCoverDate</c>
|
||||
/// </summary>
|
||||
public string Year { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// V1-only format tag (e.g. "Main Series", "Annual"). Maps to ComicInfo Format
|
||||
/// </summary>
|
||||
public string Format { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// V1-only file type hint (Kavita extension, not part of the CBL standard)
|
||||
/// </summary>
|
||||
public string FileType { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Full cover date string from V2 (ISO 8601 YYYY-MM-DD). Empty for V1
|
||||
/// </summary>
|
||||
public string CoverDate { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Issue classification from V2. Always <see cref="CblIssueType.Unknown"/> for V1
|
||||
/// </summary>
|
||||
public CblIssueType IssueType { get; set; } = CblIssueType.Unknown;
|
||||
/// <summary>
|
||||
/// External database references for this issue.
|
||||
/// </summary>
|
||||
public List<CblExternalId> ExternalIds { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
/// <summary>
|
||||
/// Unified reading list model produced by parsing either a V1 XML or V2 JSON CBL file
|
||||
/// </summary>
|
||||
public sealed record ParsedCblReadingList
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique file identifier
|
||||
/// </summary>
|
||||
/// <remarks>V2 only - empty for V1</remarks>
|
||||
public string Uuid { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// CBL schema version (1 for XML, 2+ for JSON)
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; } = 1;
|
||||
/// <summary>
|
||||
/// Display name of the reading list
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Human-readable summary or description
|
||||
/// </summary>
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Free-form notes
|
||||
/// </summary>
|
||||
/// <remarks>V2 only - empty for V1</remarks>
|
||||
public string Notes { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Start year of the reading list. -1 if not specified
|
||||
/// </summary>
|
||||
public int StartYear { get; set; } = -1;
|
||||
/// <summary>
|
||||
/// Start month. V1 only - -1 if not specified
|
||||
/// </summary>
|
||||
public int StartMonth { get; set; } = -1;
|
||||
/// <summary>
|
||||
/// End year of the reading list. -1 if not specified
|
||||
/// </summary>
|
||||
public int EndYear { get; set; } = -1;
|
||||
/// <summary>
|
||||
/// End month
|
||||
/// </summary>
|
||||
/// <remarks>V1 only - -1 if not specified.</remarks>
|
||||
public int EndMonth { get; set; } = -1;
|
||||
/// <summary>
|
||||
/// Primary publisher
|
||||
/// </summary>
|
||||
/// <remarks>V2 only - empty for V1.</remarks>
|
||||
public string Publisher { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Publisher imprint
|
||||
/// </summary>
|
||||
/// <remarks>V2 only - empty for V1.</remarks>
|
||||
public string Imprint { get; set; } = string.Empty;
|
||||
/// <summary>
|
||||
/// Classification of the list (master, character, story, etc.)
|
||||
/// </summary>
|
||||
/// <remarks>V2 only</remarks>
|
||||
public CblListType ListType { get; set; } = CblListType.Unknown;
|
||||
/// <summary>
|
||||
/// User-defined tags
|
||||
/// </summary>
|
||||
/// <remarks>V2 only</remarks>
|
||||
public List<string> Tags { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Cover image URLs
|
||||
/// </summary>
|
||||
/// <remarks>V2 only</remarks>
|
||||
public List<string> CoverImageUrls { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Related reading lists
|
||||
/// </summary>
|
||||
/// <remarks>V2 only</remarks>
|
||||
public List<CblRelationship> Relationships { get; set; } = new();
|
||||
/// <summary>
|
||||
/// External sources the list was derived from
|
||||
/// </summary>
|
||||
/// <remarks>V2 only</remarks>
|
||||
public List<CblSource> Sources { get; set; } = new();
|
||||
/// <summary>
|
||||
/// Ordered list of issues/books in the reading list.
|
||||
/// </summary>
|
||||
public List<ParsedCblItem> Items { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
|
||||
/// <summary>
|
||||
/// External database reference embedded in a V1 CBL Book entry.
|
||||
/// Maps a provider name to its series and issue identifiers
|
||||
/// </summary>
|
||||
[XmlRoot(ElementName="Database")]
|
||||
public sealed record CblBookDatabase
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider short-name (e.g. "cv" for ComicVine, "metron", "gcd")
|
||||
/// </summary>
|
||||
[XmlAttribute("Name")]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// The provider's unique identifier for the series
|
||||
/// </summary>
|
||||
[XmlAttribute("Series")]
|
||||
public string Series { get; set; }
|
||||
/// <summary>
|
||||
/// The provider's unique identifier for the issue
|
||||
/// </summary>
|
||||
[XmlAttribute("Issue")]
|
||||
public string Issue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single book (issue) entry in a V1 XML CBL reading list
|
||||
/// </summary>
|
||||
[XmlRoot(ElementName="Book")]
|
||||
public sealed record CblBook
|
||||
{
|
||||
[XmlAttribute("Series")]
|
||||
public string Series { get; set; }
|
||||
/// <summary>
|
||||
/// Chapter Number
|
||||
/// </summary>
|
||||
[XmlAttribute("Number")]
|
||||
public string Number { get; set; }
|
||||
/// <summary>
|
||||
/// Volume Number (usually for Comics they are the year)
|
||||
/// </summary>
|
||||
[XmlAttribute("Volume")]
|
||||
public string Volume { get; set; }
|
||||
[XmlAttribute("Year")]
|
||||
public string Year { get; set; }
|
||||
/// <summary>
|
||||
/// Main Series, Annual, Limited Series
|
||||
/// </summary>
|
||||
/// <remarks>This maps to <c>ComicInfo.Format</c> tag</remarks>
|
||||
[XmlAttribute("Format")]
|
||||
public string Format { get; set; }
|
||||
/// <summary>
|
||||
/// The underlying filetype
|
||||
/// </summary>
|
||||
/// <remarks>This is not part of the standard and explicitly for Kavita to support non cbz/cbr files</remarks>
|
||||
[XmlAttribute("FileType")]
|
||||
public string FileType { get; set; }
|
||||
/// <summary>
|
||||
/// External database reference (e.g. ComicVine)
|
||||
/// </summary>
|
||||
[XmlElement("Database")]
|
||||
public CblBookDatabase Database { get; set; }
|
||||
}
|
||||
+5
-2
@@ -1,7 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using System.Xml.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
|
||||
|
||||
[XmlRoot(ElementName="Books")]
|
||||
@@ -12,6 +12,9 @@ public sealed record CblBooks
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Top-level V1 XML CBL reading list. Deserialized from .cbl/.xml files
|
||||
/// </summary>
|
||||
[XmlRoot(ElementName="ReadingList")]
|
||||
public sealed record CblReadingList
|
||||
{
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An entry in <c>issueList[].id[]</c> — external database reference for an issue.
|
||||
/// </summary>
|
||||
public sealed class CblV2ExternalId
|
||||
{
|
||||
/// <summary>
|
||||
/// Provider short-name (e.g. "cv", "metron", "gcd")
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// The provider's series identifier
|
||||
/// </summary>
|
||||
[JsonPropertyName("series")]
|
||||
public string Series { get; set; }
|
||||
/// <summary>
|
||||
/// The provider's issue identifier
|
||||
/// </summary>
|
||||
[JsonPropertyName("issue")]
|
||||
public string Issue { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The <c>fileDetails</c> block — identifies the file with a UUID and schema version.
|
||||
/// </summary>
|
||||
public sealed class CblV2FileDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Unique identifier for this CBL file
|
||||
/// </summary>
|
||||
public string UUID { get; set; }
|
||||
/// <summary>
|
||||
/// Schema version number (e.g. 1.0)
|
||||
/// </summary>
|
||||
[JsonPropertyName("version")]
|
||||
public double? Version { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An entry in <c>issueList[]</c> — a single issue in the reading list
|
||||
/// </summary>
|
||||
public sealed class CblV2Issue
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the comic series
|
||||
/// </summary>
|
||||
[JsonPropertyName("seriesName")]
|
||||
public string SeriesName { get; set; }
|
||||
/// <summary>
|
||||
/// Year the series started (used to disambiguate reboots)
|
||||
/// </summary>
|
||||
[JsonPropertyName("seriesStartYear")]
|
||||
public int? SeriesStartYear { get; set; }
|
||||
/// <summary>
|
||||
/// Display issue number (e.g. "1", "Annual 2")
|
||||
/// </summary>
|
||||
[JsonPropertyName("issueNumber")]
|
||||
public string IssueNumber { get; set; }
|
||||
/// <summary>
|
||||
/// Cover date in ISO 8601 format (YYYY-MM-DD)
|
||||
/// </summary>
|
||||
[JsonPropertyName("issueCoverDate")]
|
||||
public string IssueCoverDate { get; set; }
|
||||
/// <summary>
|
||||
/// Categorisation of the issue (e.g. "event-core", "ongoing")
|
||||
/// </summary>
|
||||
[JsonPropertyName("issueType")]
|
||||
public string IssueType { get; set; }
|
||||
/// <summary>
|
||||
/// External database identifiers for this issue
|
||||
/// </summary>
|
||||
[JsonPropertyName("id")]
|
||||
public List<CblV2ExternalId> Id { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// The <c>listDetails</c> block — descriptive metadata for the reading list.
|
||||
/// </summary>
|
||||
public sealed class CblV2ListDetails
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name of the reading list
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Human-readable description / summary
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; set; }
|
||||
/// <summary>
|
||||
/// Earliest publication year covered by the list
|
||||
/// </summary>
|
||||
[JsonPropertyName("startYear")]
|
||||
public int? StartYear { get; set; }
|
||||
/// <summary>
|
||||
/// Latest publication year covered by the list
|
||||
/// </summary>
|
||||
[JsonPropertyName("endYear")]
|
||||
public int? EndYear { get; set; }
|
||||
/// <summary>
|
||||
/// Primary publisher (e.g. "Marvel", "DC")
|
||||
/// </summary>
|
||||
[JsonPropertyName("publisher")]
|
||||
public string Publisher { get; set; }
|
||||
/// <summary>
|
||||
/// Publisher imprint (e.g. "Vertigo", "Icon")
|
||||
/// </summary>
|
||||
[JsonPropertyName("imprint")]
|
||||
public string Imprint { get; set; }
|
||||
/// <summary>
|
||||
/// List type as a free-form string (mapped to <see cref="CblListType"/>)
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; }
|
||||
/// <summary>
|
||||
/// User-defined tags for categorisation
|
||||
/// </summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; }
|
||||
/// <summary>
|
||||
/// URLs for cover images associated with the list
|
||||
/// </summary>
|
||||
[JsonPropertyName("coverImageURLs")]
|
||||
public List<string> CoverImageURLs { get; set; }
|
||||
/// <summary>
|
||||
/// Links to related reading lists (prequels, sequels, etc.)
|
||||
/// </summary>
|
||||
[JsonPropertyName("relationships")]
|
||||
public List<CblV2Relationship> Relationships { get; set; }
|
||||
/// <summary>
|
||||
/// External sources that this list was derived from
|
||||
/// </summary>
|
||||
[JsonPropertyName("source")]
|
||||
public List<CblV2Source> Source { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// An entry in <c>listDetails.relationships[]</c> — links to a related reading list.
|
||||
/// </summary>
|
||||
public sealed class CblV2Relationship
|
||||
{
|
||||
/// <summary>
|
||||
/// Display name of the related reading list
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// UUID of the related reading list file
|
||||
/// </summary>
|
||||
public string UUID { get; set; }
|
||||
/// <summary>
|
||||
/// Nature of the relationship (e.g. "prequel", "sequel", "companion")
|
||||
/// </summary>
|
||||
[JsonPropertyName("relationship")]
|
||||
public string Relationship { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level V2 JSON CBL document.
|
||||
/// </summary>
|
||||
/// <remarks>https://github.com/ComicReadingLists/json-cbl-standard/blob/main/schema/1.0/comic-reading-list.schema.json</remarks>
|
||||
public sealed class CblV2Root
|
||||
{
|
||||
/// <summary>
|
||||
/// File-level metadata (UUID, schema version)
|
||||
/// </summary>
|
||||
[JsonPropertyName("fileDetails")]
|
||||
public CblV2FileDetails FileDetails { get; set; }
|
||||
/// <summary>
|
||||
/// Descriptive metadata for the reading list
|
||||
/// </summary>
|
||||
[JsonPropertyName("listDetails")]
|
||||
public CblV2ListDetails ListDetails { get; set; }
|
||||
/// <summary>
|
||||
/// Ordered list of issues in the reading list
|
||||
/// </summary>
|
||||
[JsonPropertyName("issueList")]
|
||||
public List<CblV2Issue> IssueList { get; set; }
|
||||
/// <summary>
|
||||
/// Free-form notes about the reading list
|
||||
/// </summary>
|
||||
[JsonPropertyName("notes")]
|
||||
public string Notes { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
/// <summary>
|
||||
/// An entry in <c>listDetails.source[]</c> — origin of the reading list data
|
||||
/// </summary>
|
||||
public sealed class CblV2Source
|
||||
{
|
||||
/// <summary>
|
||||
/// Name of the source (e.g. "Comic Book Herald")
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; set; }
|
||||
/// <summary>
|
||||
/// URL of the source
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; }
|
||||
}
|
||||
@@ -5,7 +5,7 @@ using Kavita.Models.Entities.Interfaces;
|
||||
namespace Kavita.Models.DTOs;
|
||||
#nullable enable
|
||||
|
||||
public sealed record SeriesDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
public sealed record SeriesDto : IHasReadTimeEstimate, IHasCoverImage, IHasMetadataIds
|
||||
{
|
||||
/// <inheritdoc cref="API.Entities.Series.Id"/>
|
||||
public int Id { get; init; }
|
||||
@@ -100,6 +100,15 @@ public sealed record SeriesDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
public string? SecondaryColor { get; set; } = string.Empty;
|
||||
#endregion
|
||||
|
||||
#region Metadata
|
||||
public int AniListId { get; set; }
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
#endregion
|
||||
|
||||
public void ResetColorScape()
|
||||
{
|
||||
PrimaryColor = string.Empty;
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Kavita.Models.DTOs.Common;
|
||||
using Kavita.Models.DTOs.Metadata;
|
||||
using Kavita.Models.DTOs.Person;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
|
||||
namespace Kavita.Models.DTOs;
|
||||
|
||||
public sealed record UpdateChapterDto
|
||||
#nullable enable
|
||||
|
||||
public sealed record UpdateChapterDto : IUpdateExternalMetadataIds
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string Summary { get; set; } = string.Empty;
|
||||
@@ -92,4 +95,11 @@ public sealed record UpdateChapterDto
|
||||
/// </summary>
|
||||
/// <remarks>This should not be confused with Title which is used for special filenames.</remarks>
|
||||
public string TitleName { get; set; } = string.Empty;
|
||||
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
public long? MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long? MangaBakaId { get; set; }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
namespace Kavita.Models.DTOs;
|
||||
using Kavita.Models.DTOs.Common;
|
||||
|
||||
namespace Kavita.Models.DTOs;
|
||||
#nullable enable
|
||||
|
||||
public sealed record UpdateSeriesDto
|
||||
public sealed record UpdateSeriesDto : IUpdateExternalMetadataIds
|
||||
{
|
||||
public int Id { get; init; }
|
||||
public string? LocalizedName { get; init; }
|
||||
@@ -10,4 +12,13 @@ public sealed record UpdateSeriesDto
|
||||
|
||||
public bool SortNameLocked { get; set; }
|
||||
public bool LocalizedNameLocked { get; set; }
|
||||
|
||||
#region External Metadata Ids
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
public long? MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long? MangaBakaId { get; set; }
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace Kavita.Models.DTOs;
|
||||
using Kavita.Models.DTOs.Common;
|
||||
|
||||
namespace Kavita.Models.DTOs;
|
||||
|
||||
public sealed record UpdateSeriesMetadataDto
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
using Kavita.Models.DTOs.Common;
|
||||
|
||||
namespace Kavita.Models.DTOs;
|
||||
|
||||
public sealed record UpdateVolumeDto : IUpdateExternalMetadataIds
|
||||
{
|
||||
public int Id { get; init; }
|
||||
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public int? HardcoverId { get; set; }
|
||||
public long? MetronId { get; set; }
|
||||
public string ComicVineId { get; set; }
|
||||
public long? MangaBakaId { get; set; }
|
||||
}
|
||||
@@ -4,7 +4,7 @@ using Kavita.Models.Entities.Interfaces;
|
||||
|
||||
namespace Kavita.Models.DTOs;
|
||||
|
||||
public sealed record VolumeDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
public sealed record VolumeDto : IHasReadTimeEstimate, IHasCoverImage, IHasMetadataIds
|
||||
{
|
||||
/// <inheritdoc cref="API.Entities.Volume.Id"/>
|
||||
public int Id { get; set; }
|
||||
@@ -56,6 +56,15 @@ public sealed record VolumeDto : IHasReadTimeEstimate, IHasCoverImage
|
||||
/// <inheritdoc cref="API.Entities.Volume.SecondaryColor"/>
|
||||
public string? SecondaryColor { get; set; } = string.Empty;
|
||||
|
||||
#region Metadata
|
||||
public int AniListId { get; set; }
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
#endregion
|
||||
|
||||
public void ResetColorScape()
|
||||
{
|
||||
PrimaryColor = string.Empty;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.Interfaces;
|
||||
using Kavita.Models.Entities.Metadata;
|
||||
@@ -11,7 +10,7 @@ using Kavita.Models.Entities.User;
|
||||
|
||||
namespace Kavita.Models.Entities;
|
||||
|
||||
public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKPlusMetadata
|
||||
public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKPlusMetadata, IHasMetadataIds
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@@ -39,10 +38,6 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKP
|
||||
/// Can the sort order be updated on scan or is it locked from UI
|
||||
/// </summary>
|
||||
public bool SortOrderLocked { get; set; }
|
||||
/// <summary>
|
||||
/// The files that represent this Chapter
|
||||
/// </summary>
|
||||
public ICollection<MangaFile> Files { get; set; } = null!;
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
@@ -137,6 +132,15 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKP
|
||||
/// </summary>
|
||||
public float AverageExternalRating { get; set; } = 0f;
|
||||
|
||||
#region Metadata
|
||||
public int AniListId { get; set; }
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Locks
|
||||
|
||||
public bool AgeRatingLocked { get; set; }
|
||||
@@ -175,9 +179,12 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasKP
|
||||
public ICollection<AppUserChapterRating> Ratings { get; set; } = [];
|
||||
|
||||
public ICollection<AppUserProgress> UserProgress { get; set; }
|
||||
/// <summary>
|
||||
/// The files that represent this Chapter
|
||||
/// </summary>
|
||||
public ICollection<MangaFile> Files { get; set; } = null!;
|
||||
|
||||
|
||||
// Relationships
|
||||
public Volume Volume { get; set; } = null!;
|
||||
public int VolumeId { get; set; }
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace Kavita.Models.Entities.Interfaces;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// An entity has metadata markers
|
||||
/// </summary>
|
||||
public interface IHasMetadataIds
|
||||
{
|
||||
public int AniListId { get; set; }
|
||||
/// <summary>
|
||||
/// https://myanimelist.net/store/manga/{MalId}/Blue_Lock
|
||||
/// </summary>
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string? ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
}
|
||||
@@ -38,7 +38,6 @@ public class Person : IHasCoverImage
|
||||
/// </summary>
|
||||
/// <remarks>Kavita+ Only</remarks>
|
||||
public string? HardcoverId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// https://metron.cloud/creator/{slug}/
|
||||
/// </summary>
|
||||
|
||||
@@ -8,7 +8,7 @@ using Kavita.Models.Entities.User;
|
||||
|
||||
namespace Kavita.Models.Entities;
|
||||
|
||||
public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasMetadataIds
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@@ -16,11 +16,11 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
/// </summary>
|
||||
public required string Name { get; set; }
|
||||
/// <summary>
|
||||
/// Used internally for name matching. <see cref="Services.Tasks.Scanner.Parser.Parser.Normalize"/>
|
||||
/// Used internally for name matching. <see cref="Kavita.Services.Tasks.Scanner.Parser.Parser.Normalize"/>
|
||||
/// </summary>
|
||||
public required string NormalizedName { get; set; }
|
||||
/// <summary>
|
||||
/// Used internally for localized name matching. <see cref="Services.Tasks.Scanner.Parser.Parser.Normalize"/>
|
||||
/// Used internally for localized name matching. <see cref="Kavita.Services.Tasks.Scanner.Parser.Parser.Normalize"/>
|
||||
/// </summary>
|
||||
public required string NormalizedLocalizedName { get; set; }
|
||||
/// <summary>
|
||||
@@ -116,6 +116,15 @@ public class Series : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public bool IsBlacklisted { get; set; }
|
||||
#endregion
|
||||
|
||||
#region Metadata
|
||||
public int AniListId { get; set; }
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
#endregion
|
||||
|
||||
public SeriesMetadata Metadata { get; set; } = null!;
|
||||
public ExternalSeriesMetadata ExternalSeriesMetadata { get; set; } = null!;
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ using Kavita.Models.Entities.Interfaces;
|
||||
|
||||
namespace Kavita.Models.Entities;
|
||||
|
||||
public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage, IHasMetadataIds
|
||||
{
|
||||
public int Id { get; set; }
|
||||
/// <summary>
|
||||
@@ -54,6 +54,15 @@ public class Volume : IEntityDate, IHasReadTimeEstimate, IHasCoverImage
|
||||
public int MaxHoursToRead { get; set; }
|
||||
public float AvgHoursToRead { get; set; }
|
||||
|
||||
#region Metadata
|
||||
public int AniListId { get; set; }
|
||||
public long MalId { get; set; }
|
||||
public int HardcoverId { get; set; }
|
||||
public long MetronId { get; set; }
|
||||
public string ComicVineId { get; set; }
|
||||
public long MangaBakaId { get; set; }
|
||||
#endregion
|
||||
|
||||
|
||||
// Relationships
|
||||
public IList<Chapter> Chapters { get; set; } = null!;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.Interfaces;
|
||||
using Kavita.Models.Metadata;
|
||||
|
||||
namespace Kavita.Models.Parser;
|
||||
#nullable enable
|
||||
|
||||
/// <summary>
|
||||
/// This represents all parsed information from a single file
|
||||
@@ -83,5 +85,32 @@ public class ParserInfo
|
||||
/// </summary>
|
||||
public ComicInfo? ComicInfo { get; set; }
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Extracted from Notes/Weblink fields, not explicitly part of spec
|
||||
/// </summary>
|
||||
public int? AniListId { get; set; }
|
||||
/// <summary>
|
||||
/// Extracted from Notes field, not explicitly part of spec
|
||||
/// </summary>
|
||||
public long? MalId { get; set; }
|
||||
/// <summary>
|
||||
/// Extracted from Notes field, not explicitly part of spec
|
||||
/// </summary>
|
||||
public int? HardcoverId { get; set; }
|
||||
/// <summary>
|
||||
/// Extracted from Notes field, not explicitly part of spec
|
||||
/// </summary>
|
||||
public long? MetronId { get; set; }
|
||||
/// <summary>
|
||||
/// Extracted from Notes field, not explicitly part of spec
|
||||
/// </summary>
|
||||
public string? ComicVineId { get; set; }
|
||||
/// <summary>
|
||||
/// If the ComicVine slug starts with 4050, it's a Volume/Series Id
|
||||
/// </summary>
|
||||
public string? ComicVineSeriesId { get; set; }
|
||||
/// <summary>
|
||||
/// Extracted from Notes field, not explicitly part of spec
|
||||
/// </summary>
|
||||
public long? MangaBakaId { get; set; }
|
||||
}
|
||||
|
||||
@@ -5,8 +5,10 @@ using System.Threading.Tasks;
|
||||
using Kavita.API.Attributes;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.Models.Constants;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
using Kavita.Server.Attributes;
|
||||
using Kavita.Services.Reading;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -18,10 +20,7 @@ namespace Kavita.Server.Controllers;
|
||||
/// <summary>
|
||||
/// Responsible for the CBL import flow
|
||||
/// </summary>
|
||||
public class CblController(
|
||||
IReadingListService readingListService,
|
||||
IDirectoryService directoryService)
|
||||
: BaseApiController
|
||||
public class CblController( IReadingListService readingListService, IDirectoryService directoryService) : BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
/// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful.
|
||||
|
||||
@@ -11,9 +11,12 @@ using Kavita.Common.Extensions;
|
||||
using Kavita.Models.Constants;
|
||||
using Kavita.Models.DTOs;
|
||||
using Kavita.Models.DTOs.SignalR;
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.Interfaces;
|
||||
using Kavita.Models.Entities.MetadataMatching;
|
||||
using Kavita.Server.Attributes;
|
||||
using Kavita.Server.Helpers;
|
||||
using Kavita.Services.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -247,6 +250,8 @@ public class ChapterController(
|
||||
);
|
||||
}
|
||||
|
||||
ExternalMetadataIdHelper.SetExternalMetadataIds(chapter, dto);
|
||||
|
||||
|
||||
#region Genres
|
||||
chapter.Genres ??= [];
|
||||
@@ -412,6 +417,7 @@ public class ChapterController(
|
||||
return Ok();
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns Ratings and Reviews for an individual Chapter
|
||||
/// </summary>
|
||||
|
||||
@@ -7,6 +7,7 @@ using Kavita.API.Database;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Metadata;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.Models.Constants;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Extensions;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Kavita.API.Attributes;
|
||||
@@ -6,6 +7,7 @@ using Kavita.API.Database;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.Constants;
|
||||
@@ -15,6 +17,7 @@ using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Server.Attributes;
|
||||
using Kavita.Server.Extensions;
|
||||
using Kavita.Services.Reading;
|
||||
using Kavita.Services.ReadingLists;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -24,7 +27,8 @@ namespace Kavita.Server.Controllers;
|
||||
public class ReadingListController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IReadingListService readingListService,
|
||||
ILocalizationService localizationService)
|
||||
ILocalizationService localizationService,
|
||||
ICblExportService cblExportService)
|
||||
: BaseApiController
|
||||
{
|
||||
/// <summary>
|
||||
@@ -610,4 +614,21 @@ public class ReadingListController(
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Export a Reading List to CBL format
|
||||
/// </summary>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <param name="asV2"></param>
|
||||
/// <returns></returns>
|
||||
[ReadingListAccess]
|
||||
[HttpPost("export-as-cbl")]
|
||||
public async Task<ActionResult> ExportAsCbl([FromQuery] int readingListId, [FromQuery] bool asV2 = false)
|
||||
{
|
||||
var filepath = await cblExportService.ExportReadingList(readingListId, UserId, asV2);
|
||||
if (string.IsNullOrEmpty(filepath)) return BadRequest(localizationService.Translate(UserId, "cbl-export-failed"));
|
||||
|
||||
var contentType = asV2 ? "application/json" : "application/xml";
|
||||
return PhysicalFile(filepath, contentType, Path.GetFileName(filepath));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.MetadataMatching;
|
||||
using Kavita.Server.Attributes;
|
||||
using Kavita.Server.Extensions;
|
||||
using Kavita.Server.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -178,6 +179,8 @@ public class SeriesController(
|
||||
series.SortNameLocked = updateSeries.SortNameLocked;
|
||||
series.LocalizedNameLocked = updateSeries.LocalizedNameLocked;
|
||||
|
||||
ExternalMetadataIdHelper.SetExternalMetadataIds(series, updateSeries);
|
||||
|
||||
|
||||
var needsRefreshMetadata = false;
|
||||
// This is when you hit Reset
|
||||
|
||||
@@ -7,6 +7,7 @@ using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Metadata;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Common.Extensions;
|
||||
using Kavita.Models.Constants;
|
||||
|
||||
@@ -7,6 +7,7 @@ using Kavita.Models.Constants;
|
||||
using Kavita.Models.DTOs;
|
||||
using Kavita.Models.DTOs.SignalR;
|
||||
using Kavita.Server.Attributes;
|
||||
using Kavita.Server.Helpers;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -27,6 +28,33 @@ public class VolumeController(IUnitOfWork unitOfWork, ILocalizationService local
|
||||
return Ok(await unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, UserId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the information on the Volume
|
||||
/// </summary>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("update")]
|
||||
[Authorize(Policy = PolicyGroups.AdminPolicy)]
|
||||
public async Task<ActionResult<VolumeDto>> UpdateVolume(UpdateVolumeDto dto)
|
||||
{
|
||||
var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(dto.Id);
|
||||
if (volume == null) return BadRequest(localizationService.Translate(UserId, "volume-doesnt-exist"));
|
||||
|
||||
ExternalMetadataIdHelper.SetExternalMetadataIds(volume, dto);
|
||||
|
||||
unitOfWork.VolumeRepository.Update(volume);
|
||||
|
||||
if (unitOfWork.HasChanges() && !await unitOfWork.CommitAsync())
|
||||
return BadRequest(localizationService.Translate(UserId, "generic-error"));
|
||||
|
||||
return Ok(await unitOfWork.VolumeRepository.GetVolumeDtoAsync(volume.Id, UserId));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete the Volume from the DB
|
||||
/// </summary>
|
||||
/// <param name="volumeId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpDelete]
|
||||
[Authorize(Policy = PolicyGroups.AdminPolicy)]
|
||||
public async Task<ActionResult<bool>> DeleteVolume(int volumeId)
|
||||
@@ -47,6 +75,11 @@ public class VolumeController(IUnitOfWork unitOfWork, ILocalizationService local
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete multiple Volumes from the DB
|
||||
/// </summary>
|
||||
/// <param name="volumesIds"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("multiple")]
|
||||
[Authorize(Policy = PolicyGroups.AdminPolicy)]
|
||||
public async Task<ActionResult<bool>> DeleteMultipleVolumes(int[] volumesIds)
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Kavita.Models.DTOs;
|
||||
using Kavita.Models.DTOs.Common;
|
||||
using Kavita.Models.Entities.Interfaces;
|
||||
|
||||
namespace Kavita.Server.Helpers;
|
||||
|
||||
public static class ExternalMetadataIdHelper
|
||||
{
|
||||
public static void SetExternalMetadataIds(IHasMetadataIds entity, IUpdateExternalMetadataIds dto)
|
||||
{
|
||||
if (dto.AniListId is > 0)
|
||||
{
|
||||
entity.AniListId = dto.AniListId.Value;
|
||||
}
|
||||
|
||||
if (dto.MalId is > 0)
|
||||
{
|
||||
entity.MalId = dto.MalId.Value;
|
||||
}
|
||||
|
||||
if (dto.MangaBakaId is > 0)
|
||||
{
|
||||
entity.MangaBakaId = dto.MangaBakaId.Value;
|
||||
}
|
||||
|
||||
if (dto.HardcoverId is > 0)
|
||||
{
|
||||
entity.HardcoverId = dto.HardcoverId.Value;
|
||||
}
|
||||
|
||||
if (dto.MetronId is > 0)
|
||||
{
|
||||
entity.MetronId = dto.MetronId.Value;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dto.ComicVineId))
|
||||
{
|
||||
entity.ComicVineId = dto.ComicVineId;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +221,7 @@
|
||||
"genre-doesnt-exist": "Genre doesn't exist",
|
||||
"font-url-not-allowed": "Uploading a Font by url is only allowed from Google Fonts",
|
||||
"annotation-export-failed": "Unable to export Annotations, check logs",
|
||||
"cbl-export-failed": "Unable to export CBL file, check logs",
|
||||
"download-not-allowed": "User does not have download permissions",
|
||||
"auth-key-unique": "The Auth Key name must be unique to your account",
|
||||
"role-restricted": "Access forbidden: Your role does not permit this action"
|
||||
|
||||
@@ -159,4 +159,11 @@
|
||||
<_DeploymentManifestIconFile Remove="favicon.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -329,6 +329,18 @@ public class ArchiveServiceTests
|
||||
Assert.Equal("https://www.comixology.com/BTOOOM/digital-comic/450184", comicInfo.Web);
|
||||
}
|
||||
|
||||
// [Fact]
|
||||
// public void CanParseMetadataIdFromComicInfo()
|
||||
// {
|
||||
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Test Data/ArchiveService/ComicInfos");
|
||||
// var archive = Path.Join(testDirectory, "metadata_from_notes.cbz");
|
||||
// var comicInfo = _archiveService.GetComicInfo(archive);
|
||||
//
|
||||
// Assert.NotNull(comicInfo);
|
||||
// Assert.Equal("Scraped metadata from ComicVine [CVDB734524]", comicInfo.Notes);
|
||||
// Assert.Equal("734524", comicInfo.ComicVineId);
|
||||
// }
|
||||
|
||||
#endregion
|
||||
|
||||
#region CanParseComicInfo_DefaultNumberIsBlank
|
||||
|
||||
@@ -5,6 +5,7 @@ using Kavita.Models.Parser;
|
||||
|
||||
namespace Kavita.Services.Tests.Helpers;
|
||||
|
||||
// TODO: Investigate dead code
|
||||
public static class ParserInfoFactory
|
||||
{
|
||||
public static ParserInfo CreateParsedInfo(string series, string volumes, string chapters, string filename, bool isSpecial)
|
||||
|
||||
@@ -9,10 +9,9 @@ using Kavita.API.Services;
|
||||
using Kavita.API.Services.Helpers;
|
||||
using Kavita.API.Services.Metadata;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.Scanner;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Database;
|
||||
using Kavita.Models;
|
||||
using Kavita.Models.Builders;
|
||||
using Kavita.Models.Entities;
|
||||
@@ -64,10 +63,11 @@ public class ScannerHelper
|
||||
return library;
|
||||
}
|
||||
|
||||
public ScannerService CreateServices(DirectoryService ds = null, IFileSystem fs = null)
|
||||
public ScannerService CreateServices(DirectoryService? ds = null, IFileSystem? fs = null)
|
||||
{
|
||||
fs ??= new FileSystem();
|
||||
ds ??= new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
|
||||
|
||||
var archiveService = new ArchiveService(Substitute.For<ILogger<ArchiveService>>(), ds,
|
||||
Substitute.For<IImageService>(), Substitute.For<IMediaErrorService>());
|
||||
var readingItemService = new ReadingItemService(archiveService, Substitute.For<IBookService>(),
|
||||
@@ -158,9 +158,9 @@ public class ScannerHelper
|
||||
var fileDir = Path.GetDirectoryName(fullPath);
|
||||
|
||||
// Create the directory if it doesn't exist
|
||||
if (!Directory.Exists(fileDir))
|
||||
if (!string.IsNullOrEmpty(fileDir) && !Directory.Exists(fileDir))
|
||||
{
|
||||
Directory.CreateDirectory(fileDir);
|
||||
Directory.CreateDirectory(fileDir!);
|
||||
Console.WriteLine($"Created directory: {fileDir}");
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Database;
|
||||
|
||||
@@ -5,12 +5,14 @@ using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Database;
|
||||
using Kavita.Database.Tests;
|
||||
using Kavita.Models.Builders;
|
||||
using Kavita.Models.DTOs.ReadingLists;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.User;
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.Metadata;
|
||||
using Kavita.Models.Entities.Person;
|
||||
using Kavita.Services.Helpers;
|
||||
using Kavita.Services.ReadingLists;
|
||||
|
||||
namespace Kavita.Services.Tests.ReadingLists;
|
||||
|
||||
public class CblExportServiceTests
|
||||
{
|
||||
#region Helpers
|
||||
|
||||
private static ReadingList CreateReadingList(string title = "Test List", string? summary = "A test reading list",
|
||||
int startingYear = 2020, int startingMonth = 1, int endingYear = 2021, int endingMonth = 12)
|
||||
{
|
||||
return new ReadingList
|
||||
{
|
||||
Id = 1,
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToLower(),
|
||||
Summary = summary,
|
||||
AgeRating = AgeRating.Unknown,
|
||||
StartingYear = startingYear,
|
||||
StartingMonth = startingMonth,
|
||||
EndingYear = endingYear,
|
||||
EndingMonth = endingMonth,
|
||||
};
|
||||
}
|
||||
|
||||
private static ReadingListItem CreateItem(int order, string seriesName, string chapterRange,
|
||||
string volumeName, DateTime? releaseDate = null, bool isSpecial = false,
|
||||
MangaFormat format = MangaFormat.Archive)
|
||||
{
|
||||
var series = new Series
|
||||
{
|
||||
Id = order + 1,
|
||||
Name = seriesName,
|
||||
NormalizedName = seriesName.ToLower(),
|
||||
NormalizedLocalizedName = seriesName.ToLower(),
|
||||
SortName = seriesName,
|
||||
LocalizedName = seriesName,
|
||||
OriginalName = seriesName,
|
||||
Format = format,
|
||||
Metadata = new SeriesMetadata
|
||||
{
|
||||
People = new List<SeriesMetadataPeople>(),
|
||||
},
|
||||
};
|
||||
|
||||
return new ReadingListItem
|
||||
{
|
||||
Order = order,
|
||||
Series = series,
|
||||
SeriesId = series.Id,
|
||||
Volume = new Volume
|
||||
{
|
||||
Name = volumeName,
|
||||
MinNumber = 0,
|
||||
MaxNumber = 0,
|
||||
LookupName = volumeName,
|
||||
},
|
||||
Chapter = new Chapter
|
||||
{
|
||||
Range = chapterRange,
|
||||
IsSpecial = isSpecial,
|
||||
ReleaseDate = releaseDate ?? DateTime.MinValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildCblReadingList
|
||||
|
||||
[Fact]
|
||||
public void ExportV1_BasicReadingList()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15)),
|
||||
CreateItem(1, "Batman", "2", "2016", new DateTime(2016, 7, 6)),
|
||||
CreateItem(2, "Superman", "10", "2011", new DateTime(2013, 3, 1)),
|
||||
};
|
||||
|
||||
var result = CblExportService.BuildCblReadingList(readingList, items);
|
||||
|
||||
Assert.Equal("Test List", result.Name);
|
||||
Assert.Equal("A test reading list", result.Summary);
|
||||
Assert.Equal(2020, result.StartYear);
|
||||
Assert.Equal(1, result.StartMonth);
|
||||
Assert.Equal(2021, result.EndYear);
|
||||
Assert.Equal(12, result.EndMonth);
|
||||
|
||||
Assert.Equal(3, result.Books.Book.Count);
|
||||
|
||||
var first = result.Books.Book[0];
|
||||
Assert.Equal("Batman", first.Series);
|
||||
Assert.Equal("1", first.Number);
|
||||
Assert.Equal("2016", first.Volume);
|
||||
Assert.Equal("2016", first.Year);
|
||||
Assert.Equal(string.Empty, first.Format);
|
||||
Assert.Equal("cbz", first.FileType);
|
||||
Assert.Null(first.Database);
|
||||
|
||||
var last = result.Books.Book[2];
|
||||
Assert.Equal("Superman", last.Series);
|
||||
Assert.Equal("10", last.Number);
|
||||
Assert.Equal("2011", last.Volume);
|
||||
Assert.Equal("2013", last.Year);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV1_SpecialChapter()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "Annual 1", "2016", isSpecial: true),
|
||||
};
|
||||
|
||||
var result = CblExportService.BuildCblReadingList(readingList, items);
|
||||
|
||||
Assert.Single(result.Books.Book);
|
||||
Assert.Equal("Annual", result.Books.Book[0].Format);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(MangaFormat.Archive, "cbz")]
|
||||
[InlineData(MangaFormat.Epub, "epub")]
|
||||
[InlineData(MangaFormat.Pdf, "pdf")]
|
||||
[InlineData(MangaFormat.Image, "image")]
|
||||
[InlineData(MangaFormat.Unknown, "")]
|
||||
public void ExportV1_FileTypeMappings(MangaFormat format, string expected)
|
||||
{
|
||||
Assert.Equal(expected, CblExportService.MapMangaFormatToFileType(format));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV1_EmptyItems()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>();
|
||||
|
||||
var result = CblExportService.BuildCblReadingList(readingList, items);
|
||||
|
||||
Assert.Equal("Test List", result.Name);
|
||||
Assert.Empty(result.Books.Book);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV1_DefaultReleaseDate_EmptyYear()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "1", "2016"),
|
||||
};
|
||||
|
||||
var result = CblExportService.BuildCblReadingList(readingList, items);
|
||||
|
||||
Assert.Equal(string.Empty, result.Books.Book[0].Year);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region RoundTrip
|
||||
|
||||
[Fact]
|
||||
public void ExportV1_RoundTrip()
|
||||
{
|
||||
var readingList = CreateReadingList(title: "Round Trip Test", summary: "Testing round trip");
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15)),
|
||||
CreateItem(1, "Superman", "Annual 1", "2011", new DateTime(2013, 3, 1), isSpecial: true, format: MangaFormat.Epub),
|
||||
};
|
||||
|
||||
var cbl = CblExportService.BuildCblReadingList(readingList, items);
|
||||
|
||||
var tempFile = Path.Combine(Path.GetTempPath(), $"cbl-export-test-{Guid.NewGuid()}.cbl");
|
||||
try
|
||||
{
|
||||
CblExportService.SerializeV1(cbl, tempFile);
|
||||
|
||||
var parsed = CblParser.ParseV1(tempFile);
|
||||
|
||||
Assert.Equal("Round Trip Test", parsed.Name);
|
||||
Assert.Equal("Testing round trip", parsed.Summary);
|
||||
Assert.Equal(2020, parsed.StartYear);
|
||||
Assert.Equal(1, parsed.StartMonth);
|
||||
Assert.Equal(2021, parsed.EndYear);
|
||||
Assert.Equal(12, parsed.EndMonth);
|
||||
|
||||
Assert.Equal(2, parsed.Items.Count);
|
||||
|
||||
var first = parsed.Items[0];
|
||||
Assert.Equal("Batman", first.SeriesName);
|
||||
Assert.Equal("1", first.Number);
|
||||
Assert.Equal("2016", first.Volume);
|
||||
Assert.Equal("2016", first.Year);
|
||||
Assert.Equal(string.Empty, first.Format);
|
||||
Assert.Equal("cbz", first.FileType);
|
||||
|
||||
var second = parsed.Items[1];
|
||||
Assert.Equal("Superman", second.SeriesName);
|
||||
Assert.Equal("Annual 1", second.Number);
|
||||
Assert.Equal("2011", second.Volume);
|
||||
Assert.Equal("2013", second.Year);
|
||||
Assert.Equal("Annual", second.Format);
|
||||
Assert.Equal("epub", second.FileType);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile)) File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region BuildCblV2Root
|
||||
|
||||
[Fact]
|
||||
public void ExportV2_BasicReadingList()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15)),
|
||||
CreateItem(1, "Superman", "10", "2011", new DateTime(2013, 3, 1)),
|
||||
};
|
||||
|
||||
// Set ReleaseYear on series metadata
|
||||
items[0].Series.Metadata.ReleaseYear = 2016;
|
||||
items[1].Series.Metadata.ReleaseYear = 2011;
|
||||
|
||||
var result = CblExportService.BuildCblV2Root(readingList, items);
|
||||
|
||||
Assert.NotNull(result.FileDetails);
|
||||
Assert.Equal(1.0, result.FileDetails.Version);
|
||||
Assert.False(string.IsNullOrEmpty(result.FileDetails.UUID));
|
||||
|
||||
Assert.Equal("Test List", result.ListDetails.Name);
|
||||
Assert.Equal("A test reading list", result.ListDetails.Description);
|
||||
Assert.Equal(2020, result.ListDetails.StartYear);
|
||||
Assert.Equal(2021, result.ListDetails.EndYear);
|
||||
|
||||
Assert.Equal(2, result.IssueList.Count);
|
||||
|
||||
var first = result.IssueList[0];
|
||||
Assert.Equal("Batman", first.SeriesName);
|
||||
Assert.Equal("1", first.IssueNumber);
|
||||
Assert.Equal(2016, first.SeriesStartYear);
|
||||
Assert.Equal("2016-06-15", first.IssueCoverDate);
|
||||
Assert.Null(first.Id);
|
||||
|
||||
var second = result.IssueList[1];
|
||||
Assert.Equal("Superman", second.SeriesName);
|
||||
Assert.Equal("10", second.IssueNumber);
|
||||
Assert.Equal(2011, second.SeriesStartYear);
|
||||
Assert.Equal("2013-03-01", second.IssueCoverDate);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV2_EmptyItems()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>();
|
||||
|
||||
var result = CblExportService.BuildCblV2Root(readingList, items);
|
||||
|
||||
Assert.Equal("Test List", result.ListDetails.Name);
|
||||
Assert.Empty(result.IssueList);
|
||||
Assert.Equal(string.Empty, result.ListDetails.Publisher);
|
||||
Assert.Equal(string.Empty, result.ListDetails.Imprint);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV2_DefaultReleaseDate_EmptyCoverDate()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "1", "2016"),
|
||||
};
|
||||
|
||||
var result = CblExportService.BuildCblV2Root(readingList, items);
|
||||
|
||||
Assert.Equal(string.Empty, result.IssueList[0].IssueCoverDate);
|
||||
Assert.Null(result.IssueList[0].SeriesStartYear);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV2_PublisherFromMostCommonPerson()
|
||||
{
|
||||
var readingList = CreateReadingList();
|
||||
var publisher = new Person
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Marvel",
|
||||
NormalizedName = "marvel",
|
||||
Description = string.Empty,
|
||||
PrimaryColor = string.Empty,
|
||||
SecondaryColor = string.Empty,
|
||||
};
|
||||
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Spider-Man", "1", "2018"),
|
||||
CreateItem(1, "Avengers", "1", "2018"),
|
||||
};
|
||||
|
||||
items[0].Series.Metadata.People = new List<SeriesMetadataPeople>
|
||||
{
|
||||
new() { Role = PersonRole.Publisher, Person = publisher },
|
||||
};
|
||||
items[1].Series.Metadata.People = new List<SeriesMetadataPeople>
|
||||
{
|
||||
new() { Role = PersonRole.Publisher, Person = publisher },
|
||||
};
|
||||
|
||||
var result = CblExportService.BuildCblV2Root(readingList, items);
|
||||
|
||||
Assert.Equal("Marvel", result.ListDetails.Publisher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExportV2_RoundTrip()
|
||||
{
|
||||
var readingList = CreateReadingList(title: "V2 Round Trip", summary: "Testing V2 round trip");
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15)),
|
||||
CreateItem(1, "Superman", "10", "2011", new DateTime(2013, 3, 1)),
|
||||
};
|
||||
items[0].Series.Metadata.ReleaseYear = 2016;
|
||||
items[1].Series.Metadata.ReleaseYear = 2011;
|
||||
|
||||
var v2 = CblExportService.BuildCblV2Root(readingList, items);
|
||||
|
||||
var tempFile = Path.Combine(Path.GetTempPath(), $"cbl-export-test-{Guid.NewGuid()}.json");
|
||||
try
|
||||
{
|
||||
CblExportService.SerializeV2(v2, tempFile);
|
||||
|
||||
var parsed = CblParser.ParseV2(tempFile);
|
||||
|
||||
Assert.Equal("V2 Round Trip", parsed.Name);
|
||||
Assert.Equal("Testing V2 round trip", parsed.Summary);
|
||||
Assert.Equal(2020, parsed.StartYear);
|
||||
Assert.Equal(2021, parsed.EndYear);
|
||||
|
||||
Assert.Equal(2, parsed.Items.Count);
|
||||
|
||||
var first = parsed.Items[0];
|
||||
Assert.Equal("Batman", first.SeriesName);
|
||||
Assert.Equal("1", first.Number);
|
||||
Assert.Equal("2016", first.Volume);
|
||||
Assert.Equal("2016", first.Year);
|
||||
|
||||
var second = parsed.Items[1];
|
||||
Assert.Equal("Superman", second.SeriesName);
|
||||
Assert.Equal("10", second.Number);
|
||||
Assert.Equal("2011", second.Volume);
|
||||
Assert.Equal("2013", second.Year);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(tempFile)) File.Delete(tempFile);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region GetMostCommonPerson
|
||||
|
||||
[Fact]
|
||||
public void GetMostCommonPerson_ReturnsMostFrequent()
|
||||
{
|
||||
var personA = new Person
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Publisher A",
|
||||
NormalizedName = "publisher a",
|
||||
Description = string.Empty,
|
||||
PrimaryColor = string.Empty,
|
||||
SecondaryColor = string.Empty,
|
||||
};
|
||||
var personB = new Person
|
||||
{
|
||||
Id = 2,
|
||||
Name = "Publisher B",
|
||||
NormalizedName = "publisher b",
|
||||
Description = string.Empty,
|
||||
PrimaryColor = string.Empty,
|
||||
SecondaryColor = string.Empty,
|
||||
};
|
||||
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Series1", "1", "2020"),
|
||||
CreateItem(1, "Series2", "1", "2020"),
|
||||
CreateItem(2, "Series3", "1", "2020"),
|
||||
};
|
||||
|
||||
// Series1 and Series3 have Publisher A, Series2 has Publisher B
|
||||
items[0].Series.Metadata.People = new List<SeriesMetadataPeople>
|
||||
{
|
||||
new() { Role = PersonRole.Publisher, Person = personA },
|
||||
};
|
||||
items[1].Series.Metadata.People = new List<SeriesMetadataPeople>
|
||||
{
|
||||
new() { Role = PersonRole.Publisher, Person = personB },
|
||||
};
|
||||
items[2].Series.Metadata.People = new List<SeriesMetadataPeople>
|
||||
{
|
||||
new() { Role = PersonRole.Publisher, Person = personA },
|
||||
};
|
||||
|
||||
var result = CblExportService.GetMostCommonPerson(items, PersonRole.Publisher);
|
||||
|
||||
Assert.Equal("Publisher A", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetMostCommonPerson_NoPeople_ReturnsNull()
|
||||
{
|
||||
var items = new List<ReadingListItem>
|
||||
{
|
||||
CreateItem(0, "Series1", "1", "2020"),
|
||||
};
|
||||
|
||||
var result = CblExportService.GetMostCommonPerson(items, PersonRole.Publisher);
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
using Kavita.Services.Helpers;
|
||||
using Kavita.Services.ReadingLists;
|
||||
|
||||
namespace Kavita.Services.Tests.ReadingLists;
|
||||
|
||||
public class CblParserTests
|
||||
{
|
||||
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Test Data/CblParserTests/Test Cases");
|
||||
|
||||
#region V1 Spec
|
||||
|
||||
[Fact]
|
||||
public void ParseV1Test_NoSpecial()
|
||||
{
|
||||
const string filename = "[DC Comics] Aquaman- Death of a Prince (WEB-CBRO).cbl";
|
||||
var result = CblParser.ParseV1(Path.Join(_testDirectory, filename));
|
||||
|
||||
Assert.Equal(1, result.SchemaVersion);
|
||||
Assert.Equal("[DC Comics] Aquaman- Death of a Prince (WEB-CBRO)", result.Name);
|
||||
Assert.Empty(result.Uuid);
|
||||
Assert.Equal(CblListType.Unknown, result.ListType);
|
||||
Assert.Equal(-1, result.StartYear);
|
||||
Assert.Equal(-1, result.EndYear);
|
||||
|
||||
Assert.Equal(25, result.Items.Count);
|
||||
|
||||
// First item
|
||||
var first = result.Items[0];
|
||||
Assert.Equal(0, first.Order);
|
||||
Assert.Equal("Adventure Comics", first.SeriesName);
|
||||
Assert.Equal("435", first.Number);
|
||||
Assert.Equal("1938", first.Volume);
|
||||
Assert.Equal("1974", first.Year);
|
||||
Assert.Equal(CblIssueType.Unknown, first.IssueType);
|
||||
Assert.Single(first.ExternalIds);
|
||||
Assert.Equal(CblExternalDbProvider.ComicVine, first.ExternalIds[0].Provider);
|
||||
Assert.Equal("3105", first.ExternalIds[0].SeriesId);
|
||||
Assert.Equal("124869", first.ExternalIds[0].IssueId);
|
||||
|
||||
// Last item (Aquaman #63)
|
||||
var last = result.Items[24];
|
||||
Assert.Equal(24, last.Order);
|
||||
Assert.Equal("Aquaman", last.SeriesName);
|
||||
Assert.Equal("63", last.Number);
|
||||
Assert.Equal("1962", last.Volume);
|
||||
Assert.Equal("1978", last.Year);
|
||||
Assert.Equal(CblExternalDbProvider.ComicVine, last.ExternalIds[0].Provider);
|
||||
Assert.Equal("2050", last.ExternalIds[0].SeriesId);
|
||||
Assert.Equal("137565", last.ExternalIds[0].IssueId);
|
||||
|
||||
// Item 15 (transition from Adventure Comics to Aquaman)
|
||||
var item15 = result.Items[15];
|
||||
Assert.Equal("Aquaman", item15.SeriesName);
|
||||
Assert.Equal("57", item15.Number);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseV1Test_Special()
|
||||
{
|
||||
const string filename = "BOOM! Power Rangers Simplified 1a.cbl";
|
||||
var result = CblParser.ParseV1(Path.Join(_testDirectory, filename));
|
||||
|
||||
Assert.Equal("Simplified Power Rangers 1a", result.Name);
|
||||
Assert.Equal(164, result.Items.Count);
|
||||
|
||||
// First item
|
||||
var first = result.Items[0];
|
||||
Assert.Equal("Mighty Morphin Power Rangers", first.SeriesName);
|
||||
Assert.Equal("0", first.Number);
|
||||
Assert.Equal("2016", first.Volume);
|
||||
Assert.Equal("2016", first.Year);
|
||||
Assert.Single(first.ExternalIds);
|
||||
Assert.Equal(CblExternalDbProvider.ComicVine, first.ExternalIds[0].Provider);
|
||||
Assert.Equal("87332", first.ExternalIds[0].SeriesId);
|
||||
Assert.Equal("511002", first.ExternalIds[0].IssueId);
|
||||
|
||||
// Last item
|
||||
var last = result.Items[163];
|
||||
Assert.Equal("Power Rangers Unlimited: The Morphin Masters", last.SeriesName);
|
||||
Assert.Equal("1", last.Number);
|
||||
Assert.Equal("2024", last.Volume);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseV1Test_DatabaseElementCaptured()
|
||||
{
|
||||
const string filename = "[DC Comics] Aquaman- Death of a Prince (WEB-CBRO).cbl";
|
||||
var result = CblParser.ParseV1(Path.Join(_testDirectory, filename));
|
||||
|
||||
// Every item in this file has a Database element
|
||||
foreach (var item in result.Items)
|
||||
{
|
||||
Assert.Single(item.ExternalIds);
|
||||
Assert.Equal(CblExternalDbProvider.ComicVine, item.ExternalIds[0].Provider);
|
||||
Assert.False(string.IsNullOrEmpty(item.ExternalIds[0].SeriesId));
|
||||
Assert.False(string.IsNullOrEmpty(item.ExternalIds[0].IssueId));
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
#region V2 Spec
|
||||
|
||||
[Fact]
|
||||
public void ParseV2Test()
|
||||
{
|
||||
const string filename = "2018-2021 Part 16.1 Reborn Again.json";
|
||||
var result = CblParser.ParseV2(Path.Join(_testDirectory, filename));
|
||||
|
||||
// File details
|
||||
Assert.Equal("a59e4ad5-0d51-4afe-b0f5-6d3e01eb7cc7", result.Uuid);
|
||||
Assert.Equal(1, result.SchemaVersion);
|
||||
|
||||
// List details
|
||||
Assert.Equal("Part 16.1 Reborn Again", result.Name);
|
||||
Assert.Equal(2021, result.StartYear);
|
||||
Assert.Equal(2022, result.EndYear);
|
||||
Assert.Equal("Marvel", result.Publisher);
|
||||
Assert.Equal(CblListType.Universal, result.ListType);
|
||||
|
||||
// Tags
|
||||
Assert.Equal(3, result.Tags.Count);
|
||||
Assert.Contains("avengers", result.Tags);
|
||||
Assert.Contains("marvel guides", result.Tags);
|
||||
Assert.Contains("fresh start", result.Tags);
|
||||
|
||||
// Relationships
|
||||
Assert.Equal(2, result.Relationships.Count);
|
||||
var prev = result.Relationships[0];
|
||||
Assert.Equal("Part 15.4 Trial by Fire", prev.Name);
|
||||
Assert.Equal("e1eecedf-df97-4ab4-a476-ee85204e3b78", prev.Uuid);
|
||||
Assert.Equal("previous", prev.Relationship);
|
||||
var next = result.Relationships[1];
|
||||
Assert.Equal("Part 16.2 Sinister War", next.Name);
|
||||
Assert.Equal("507e7444-8f5f-46fc-a2cc-17f08c000982", next.Uuid);
|
||||
Assert.Equal("following", next.Relationship);
|
||||
|
||||
// Sources
|
||||
Assert.Single(result.Sources);
|
||||
Assert.Equal("Marvel Guides", result.Sources[0].Name);
|
||||
Assert.Equal("https://marvelguides.com/fresh-start-finale-reading-order", result.Sources[0].Url);
|
||||
|
||||
// Issues
|
||||
Assert.Equal(58, result.Items.Count);
|
||||
|
||||
// First issue - Heroes Reborn #1
|
||||
var first = result.Items[0];
|
||||
Assert.Equal(0, first.Order);
|
||||
Assert.Equal("Heroes Reborn", first.SeriesName);
|
||||
Assert.Equal("1", first.Number);
|
||||
Assert.Equal("2021", first.Volume);
|
||||
Assert.Equal("2021", first.Year);
|
||||
Assert.Equal("2021-07-01", first.CoverDate);
|
||||
Assert.Equal(CblIssueType.Unknown, first.IssueType);
|
||||
Assert.Equal(2, first.ExternalIds.Count);
|
||||
Assert.Equal(CblExternalDbProvider.ComicVine, first.ExternalIds[0].Provider);
|
||||
Assert.Equal("135903", first.ExternalIds[0].SeriesId);
|
||||
Assert.Equal("847511", first.ExternalIds[0].IssueId);
|
||||
Assert.Equal(CblExternalDbProvider.Metron, first.ExternalIds[1].Provider);
|
||||
Assert.Equal("2139", first.ExternalIds[1].SeriesId);
|
||||
Assert.Equal("29926", first.ExternalIds[1].IssueId);
|
||||
|
||||
// Black Cat #8 (item 22) - only has comicvine, no metron
|
||||
var blackCat = result.Items[22];
|
||||
Assert.Equal("Black Cat", blackCat.SeriesName);
|
||||
Assert.Equal("8", blackCat.Number);
|
||||
Assert.Equal("2020", blackCat.Volume);
|
||||
Assert.Single(blackCat.ExternalIds);
|
||||
Assert.Equal(CblExternalDbProvider.ComicVine, blackCat.ExternalIds[0].Provider);
|
||||
|
||||
// Giant-Sized Black Cat (item 25) - no coverDate
|
||||
var giantSized = result.Items[25];
|
||||
Assert.Equal("Giant-Sized Black Cat: Infinity Score", giantSized.SeriesName);
|
||||
Assert.Empty(giantSized.CoverDate);
|
||||
Assert.Empty(giantSized.Year);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseV2Test_AutoDetect()
|
||||
{
|
||||
const string filename = "2018-2021 Part 16.1 Reborn Again.json";
|
||||
var result = CblParser.Parse(Path.Join(_testDirectory, filename));
|
||||
|
||||
Assert.Equal("a59e4ad5-0d51-4afe-b0f5-6d3e01eb7cc7", result.Uuid);
|
||||
Assert.Equal("Part 16.1 Reborn Again", result.Name);
|
||||
Assert.Equal(58, result.Items.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseV1Test_AutoDetect()
|
||||
{
|
||||
const string filename = "[DC Comics] Aquaman- Death of a Prince (WEB-CBRO).cbl";
|
||||
var result = CblParser.Parse(Path.Join(_testDirectory, filename));
|
||||
|
||||
Assert.Equal("[DC Comics] Aquaman- Death of a Prince (WEB-CBRO)", result.Name);
|
||||
Assert.Equal(25, result.Items.Count);
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Kavita.API.Database;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Helpers;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.SignalR;
|
||||
@@ -624,19 +625,4 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
|
||||
|
||||
#endregion
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://anilist.co/manga/35851/Byeontaega-Doeja/", 35851)]
|
||||
[InlineData("https://anilist.co/manga/30105", 30105)]
|
||||
[InlineData("https://anilist.co/manga/30105/Kekkaishi/", 30105)]
|
||||
public void CanParseWeblink_AniList(string link, int? expectedId)
|
||||
{
|
||||
Assert.Equal(ScrobblingHelper.ExtractId<int?>(link, ScrobblingService.AniListWeblinkWebsite), expectedId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://mangadex.org/title/316d3d09-bb83-49da-9d90-11dc7ce40967/honzuki-no-gekokujou-shisho-ni-naru-tame-ni-wa-shudan-wo-erandeiraremasen-dai-3-bu-ryouchi-ni-hon-o", "316d3d09-bb83-49da-9d90-11dc7ce40967")]
|
||||
public void CanParseWeblink_MangaDex(string link, string expectedId)
|
||||
{
|
||||
Assert.Equal(ScrobblingHelper.ExtractId<string?>(link, ScrobblingService.MangaDexWeblinkWebsite), expectedId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using Kavita.API.Database;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Common.Extensions;
|
||||
using Kavita.Database.Tests;
|
||||
|
||||
Binary file not shown.
+1052
File diff suppressed because it is too large
Load Diff
+500
@@ -0,0 +1,500 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<ReadingList xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<Name>Simplified Power Rangers 1a</Name>
|
||||
<NumIssues>164</NumIssues>
|
||||
<Books>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="0" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="511002" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="1" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="517869" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="2" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="523307" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="3" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="529697" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="4" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="537278" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="5" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="540126" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="6" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="547321" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="7" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="550432" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="8" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="555577" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="9" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="562666" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="10" Volume="2016" Year="2016">
|
||||
<Database Name="cv" Series="87332" Issue="571717" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="11" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="576679" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="12" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="581603" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="13" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="587457" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="14" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="592654" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="15" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="595738" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="16" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="603180" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="1" Volume="2017" Year="2017">
|
||||
<Database Name="cv" Series="102945" Issue="609378" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="2" Volume="2017" Year="2017">
|
||||
<Database Name="cv" Series="102945" Issue="617862" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="3" Volume="2017" Year="2017">
|
||||
<Database Name="cv" Series="102945" Issue="622968" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="4" Volume="2017" Year="2017">
|
||||
<Database Name="cv" Series="102945" Issue="630613" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="5" Volume="2017" Year="2017">
|
||||
<Database Name="cv" Series="102945" Issue="649777" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="6" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="655546" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="7" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="661195" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="8" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="663627" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="17" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="609377" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="18" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="616213" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="19" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="625368" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="20" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="632572" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="21" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="641456" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="22" Volume="2016" Year="2017">
|
||||
<Database Name="cv" Series="87332" Issue="647995" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="23" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="654090" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="24" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="660711" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="25" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="664347" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="26" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="666853" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers Free Comic Book Day 2018 Special" Number="1" Volume="2018" Year="2018">
|
||||
<Database Name="cv" Series="110629" Issue="669091" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers 2018 Annual" Number="1" Volume="2018" Year="2018">
|
||||
<Database Name="cv" Series="110105" Issue="667715" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="9" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="668813" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="10" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="672325" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="11" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="675867" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="12" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="678426" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="27" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="670154" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="28" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="674025" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="29" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="677356" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="30" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="679870" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers: Shattered Grid" Number="1" Volume="2019" Year="2019">
|
||||
<Database Name="cv" Series="120566" Issue="715246" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="31" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="686264" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="32" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="689817" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="33" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="693389" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="34" Volume="2016" Year="2018">
|
||||
<Database Name="cv" Series="87332" Issue="695566" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="35" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="699299" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="36" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="701894" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="37" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="704767" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="38" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="706907" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="39" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="710033" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="13" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="688180" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="14" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="692016" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="15" Volume="2017" Year="2018">
|
||||
<Database Name="cv" Series="102945" Issue="694858" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="16" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="696834" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="17" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="700608" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="18" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="703008" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="19" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="705850" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="20" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="709150" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers: Forever Rangers" Number="1" Volume="2019" Year="2019">
|
||||
<Database Name="cv" Series="119772" Issue="711871" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="40" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="712372" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="21" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="713454" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="22" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="716254" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="23" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="718693" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="24" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="721730" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="41" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="714280" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="42" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="717453" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="43" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="720119" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="25" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="727093" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="26" Volume="2017" Year="2019">
|
||||
<Database Name="cv" Series="102945" Issue="730300" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="27" Volume="2017" Year="2020">
|
||||
<Database Name="cv" Series="102945" Issue="733775" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="28" Volume="2017" Year="2020">
|
||||
<Database Name="cv" Series="102945" Issue="735308" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="44" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="725163" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="45" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="728955" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="46" Volume="2016" Year="2019">
|
||||
<Database Name="cv" Series="87332" Issue="731411" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="47" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="734623" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="29" Volume="2017" Year="2020">
|
||||
<Database Name="cv" Series="102945" Issue="737103" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="30" Volume="2017" Year="2020">
|
||||
<Database Name="cv" Series="102945" Issue="740713" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="31" Volume="2017" Year="2020">
|
||||
<Database Name="cv" Series="102945" Issue="763635" />
|
||||
</Book>
|
||||
<Book Series="Saban's Go Go Power Rangers" Number="32" Volume="2017" Year="2020">
|
||||
<Database Name="cv" Series="102945" Issue="767801" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="48" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="738732" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="49" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="743388" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="50" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="770064" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers: Ranger Slayer" Number="1" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="128932" Issue="782134" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers: Drakkon New Dawn" Number="1" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="129643" Issue="794898" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers: Drakkon New Dawn" Number="2" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="129643" Issue="803102" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers: Drakkon New Dawn" Number="3" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="129643" Issue="814417" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="51" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="779573" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="52" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="784139" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="53" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="796260" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="54" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="804871" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="55" Volume="2016" Year="2020">
|
||||
<Database Name="cv" Series="87332" Issue="813154" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="1" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="131698" Issue="815997" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="1" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="131836" Issue="817497" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="2" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="131698" Issue="820648" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="3" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="824326" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="4" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="828142" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="2" Volume="2020" Year="2020">
|
||||
<Database Name="cv" Series="131836" Issue="821356" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="3" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="825423" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="4" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="828810" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="5" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="836213" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="6" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="843109" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="7" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="848775" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="8" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="858795" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: Heir to Darkness" Number="1" Volume="2021" Year="2021">
|
||||
<Database Name="cv" Series="134921" Issue="840749" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="5" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="839608" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="6" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="844913" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="7" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="851276" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="8" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="864414" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: Edge of Darkness" Number="1" Volume="2021" Year="2021">
|
||||
<Database Name="cv" Series="137194" Issue="865881" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="9" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="871225" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="10" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="879107" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="11" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="884169" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="12" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="888780" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="9" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="873012" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="10" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="881336" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="11" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="884989" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="12" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="889628" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="13" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="894392" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="13" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="894450" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="14" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131698" Issue="897871" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="14" Volume="2020" Year="2021">
|
||||
<Database Name="cv" Series="131836" Issue="900366" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="15" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="902968" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="15" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="903948" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="16" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="906347" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="16" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="907457" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="17" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="910629" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="18" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="916600" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="19" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="922410" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="20" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="929694" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: Countdown to Ruin" Number="1" Volume="2022" Year="2022">
|
||||
<Database Name="cv" Series="143872" Issue="933202" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="17" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="911350" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="18" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="918445" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="19" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="925041" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="20" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="930783" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="21" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="935000" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="22" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="940164" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="21" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="935844" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers" Number="22" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131836" Issue="941575" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: The Death Ranger" Number="1" Volume="2022" Year="2022">
|
||||
<Database Name="cv" Series="144890" Issue="945155" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin" Number="100" Volume="2020" Year="2022">
|
||||
<Database Name="cv" Series="131698" Issue="949165" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="101" Volume="2016" Year="2022">
|
||||
<Database Name="cv" Series="87332" Issue="952566" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="102" Volume="2016" Year="2022">
|
||||
<Database Name="cv" Series="87332" Issue="957278" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="103" Volume="2016" Year="2022">
|
||||
<Database Name="cv" Series="87332" Issue="961207" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="104" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="964840" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="105" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="972546" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="106" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="978763" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="107" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="984101" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="108" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="990318" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="109" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="996457" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: The Coinless" Number="1" Volume="2023" Year="2023">
|
||||
<Database Name="cv" Series="151855" Issue="998475" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: HyperForce" Number="1" Volume="2023" Year="2023">
|
||||
<Database Name="cv" Series="152492" Issue="1003427" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="110" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="1005721" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="111" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="1010508" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="112" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="1023972" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="113" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="1028499" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="114" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="1038342" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="115" Volume="2016" Year="2023">
|
||||
<Database Name="cv" Series="87332" Issue="1038468" />
|
||||
</Book>
|
||||
<Book Series="Mighty Morphin Power Rangers" Number="116" Volume="2016" Year="2024">
|
||||
<Database Name="cv" Series="87332" Issue="1042313" />
|
||||
</Book>
|
||||
<Book Series="Power Rangers Unlimited: The Morphin Masters" Number="1" Volume="2024" Year="2024">
|
||||
<Database Name="cv" Series="156555" Issue="1043371" />
|
||||
</Book>
|
||||
</Books>
|
||||
<Matchers />
|
||||
</ReadingList>
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ReadingList xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<Name>[DC Comics] Aquaman- Death of a Prince (WEB-CBRO)</Name>
|
||||
<NumIssues>25</NumIssues>
|
||||
<Books>
|
||||
<Book Series="Adventure Comics" Number="435" Volume="1938" Year="1974">
|
||||
<Database Name="cv" Series="3105" Issue="124869" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="436" Volume="1938" Year="1974">
|
||||
<Database Name="cv" Series="3105" Issue="124870" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="437" Volume="1938" Year="1975">
|
||||
<Database Name="cv" Series="3105" Issue="124871" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="441" Volume="1938" Year="1975">
|
||||
<Database Name="cv" Series="3105" Issue="124858" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="442" Volume="1938" Year="1975">
|
||||
<Database Name="cv" Series="3105" Issue="124859" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="443" Volume="1938" Year="1976">
|
||||
<Database Name="cv" Series="3105" Issue="124860" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="444" Volume="1938" Year="1976">
|
||||
<Database Name="cv" Series="3105" Issue="124861" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="445" Volume="1938" Year="1976">
|
||||
<Database Name="cv" Series="3105" Issue="124862" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="446" Volume="1938" Year="1976">
|
||||
<Database Name="cv" Series="3105" Issue="124863" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="447" Volume="1938" Year="1976">
|
||||
<Database Name="cv" Series="3105" Issue="124864" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="448" Volume="1938" Year="1976">
|
||||
<Database Name="cv" Series="3105" Issue="124865" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="449" Volume="1938" Year="1977">
|
||||
<Database Name="cv" Series="3105" Issue="124866" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="450" Volume="1938" Year="1977">
|
||||
<Database Name="cv" Series="3105" Issue="116126" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="451" Volume="1938" Year="1977">
|
||||
<Database Name="cv" Series="3105" Issue="124376" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="452" Volume="1938" Year="1977">
|
||||
<Database Name="cv" Series="3105" Issue="124377" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="57" Volume="1962" Year="1977">
|
||||
<Database Name="cv" Series="2050" Issue="137567" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="58" Volume="1962" Year="1977">
|
||||
<Database Name="cv" Series="2050" Issue="115329" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="59" Volume="1962" Year="1978">
|
||||
<Database Name="cv" Series="2050" Issue="137566" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="60" Volume="1962" Year="1978">
|
||||
<Database Name="cv" Series="2050" Issue="137562" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="61" Volume="1962" Year="1978">
|
||||
<Database Name="cv" Series="2050" Issue="137568" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="453" Volume="1938" Year="1977">
|
||||
<Database Name="cv" Series="3105" Issue="124383" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="454" Volume="1938" Year="1977">
|
||||
<Database Name="cv" Series="3105" Issue="124385" />
|
||||
</Book>
|
||||
<Book Series="Adventure Comics" Number="455" Volume="1938" Year="1978">
|
||||
<Database Name="cv" Series="3105" Issue="124386" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="62" Volume="1962" Year="1978">
|
||||
<Database Name="cv" Series="2050" Issue="137564" />
|
||||
</Book>
|
||||
<Book Series="Aquaman" Number="63" Volume="1962" Year="1978">
|
||||
<Database Name="cv" Series="2050" Issue="137565" />
|
||||
</Book>
|
||||
</Books>
|
||||
<Matchers />
|
||||
</ReadingList>
|
||||
@@ -4,6 +4,7 @@ using Kavita.API.Services.Helpers;
|
||||
using Kavita.API.Services.Metadata;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.Scanner;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Services.Helpers;
|
||||
@@ -11,6 +12,7 @@ using Kavita.Services.HostedServices;
|
||||
using Kavita.Services.Metadata;
|
||||
using Kavita.Services.Plus;
|
||||
using Kavita.Services.Reading;
|
||||
using Kavita.Services.ReadingLists;
|
||||
using Kavita.Services.Scanner;
|
||||
using Kavita.Services.SignalR;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -55,6 +57,7 @@ public static class ApplicationServiceExtensions
|
||||
services.AddScoped<IFontService, FontService>();
|
||||
services.AddScoped<IAnnotationService, AnnotationService>();
|
||||
services.AddScoped<IOpdsService, OpdsService>();
|
||||
services.AddScoped<ICblExportService, CblExportService>();
|
||||
|
||||
services.AddScoped<IScannerService, ScannerService>();
|
||||
services.AddScoped<IProcessSeries, ProcessSeries>();
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using System.Xml.Serialization;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
|
||||
namespace Kavita.Services.Helpers;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for reading v1 and v2 specs into a common format
|
||||
/// </summary>
|
||||
public static class CblParser
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonSerializerOptions = new JsonSerializerOptions()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Auto-detect format by file extension and parse accordingly.
|
||||
/// </summary>
|
||||
public static ParsedCblReadingList Parse(string filePath)
|
||||
{
|
||||
var ext = Path.GetExtension(filePath).ToLowerInvariant();
|
||||
return ext switch
|
||||
{
|
||||
".cbl" or ".xml" => ParseV1(filePath),
|
||||
".json" => ParseV2(filePath),
|
||||
_ => throw new ArgumentException($"Unsupported CBL file extension: {ext}")
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a v1 XML CBL file into the unified model.
|
||||
/// </summary>
|
||||
public static ParsedCblReadingList ParseV1(string filePath)
|
||||
{
|
||||
var serializer = new XmlSerializer(typeof(CblReadingList));
|
||||
using var stream = File.OpenRead(filePath);
|
||||
var cbl = (CblReadingList)serializer.Deserialize(stream);
|
||||
|
||||
var result = new ParsedCblReadingList
|
||||
{
|
||||
SchemaVersion = 1,
|
||||
Name = cbl.Name ?? string.Empty,
|
||||
Summary = cbl.Summary ?? string.Empty,
|
||||
StartYear = cbl.StartYear,
|
||||
StartMonth = cbl.StartMonth,
|
||||
EndYear = cbl.EndYear,
|
||||
EndMonth = cbl.EndMonth,
|
||||
};
|
||||
|
||||
if (cbl.Books?.Book != null)
|
||||
{
|
||||
for (var i = 0; i < cbl.Books.Book.Count; i++)
|
||||
{
|
||||
var book = cbl.Books.Book[i];
|
||||
var item = new ParsedCblItem
|
||||
{
|
||||
Order = i,
|
||||
SeriesName = book.Series ?? string.Empty,
|
||||
Number = book.Number ?? string.Empty,
|
||||
Volume = book.Volume ?? string.Empty,
|
||||
Year = book.Year ?? string.Empty,
|
||||
Format = book.Format ?? string.Empty,
|
||||
FileType = book.FileType ?? string.Empty,
|
||||
IssueType = CblIssueType.Unknown,
|
||||
};
|
||||
|
||||
if (book.Database != null)
|
||||
{
|
||||
var provider = MapProviderName(book.Database.Name);
|
||||
item.ExternalIds.Add(new CblExternalId
|
||||
{
|
||||
Provider = provider,
|
||||
SeriesId = book.Database.Series ?? string.Empty,
|
||||
IssueId = book.Database.Issue ?? string.Empty,
|
||||
});
|
||||
}
|
||||
|
||||
result.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse a v2 JSON CBL file into the unified model.
|
||||
/// </summary>
|
||||
/// <remarks>https://github.com/ComicReadingLists/json-cbl-standard/blob/main/schema/1.0/comic-reading-list.schema.json</remarks>
|
||||
public static ParsedCblReadingList ParseV2(string filePath)
|
||||
{
|
||||
var json = File.ReadAllText(filePath);
|
||||
var v2 = JsonSerializer.Deserialize<CblV2Root>(json, JsonSerializerOptions);
|
||||
|
||||
var result = new ParsedCblReadingList
|
||||
{
|
||||
Uuid = v2.FileDetails?.UUID ?? string.Empty,
|
||||
SchemaVersion = (int)(v2.FileDetails?.Version ?? 1),
|
||||
Name = v2.ListDetails?.Name ?? string.Empty,
|
||||
Summary = v2.ListDetails?.Description ?? string.Empty,
|
||||
Notes = v2.Notes ?? string.Empty,
|
||||
StartYear = v2.ListDetails?.StartYear ?? -1,
|
||||
StartMonth = -1,
|
||||
EndYear = v2.ListDetails?.EndYear ?? -1,
|
||||
EndMonth = -1,
|
||||
Publisher = v2.ListDetails?.Publisher ?? string.Empty,
|
||||
Imprint = v2.ListDetails?.Imprint ?? string.Empty,
|
||||
ListType = MapListType(v2.ListDetails?.Type),
|
||||
Tags = v2.ListDetails?.Tags ?? [],
|
||||
CoverImageUrls = v2.ListDetails?.CoverImageURLs ?? [],
|
||||
};
|
||||
|
||||
if (v2.ListDetails?.Relationships != null)
|
||||
{
|
||||
foreach (var rel in v2.ListDetails.Relationships)
|
||||
{
|
||||
result.Relationships.Add(new CblRelationship
|
||||
{
|
||||
Name = rel.Name ?? string.Empty,
|
||||
Uuid = rel.UUID ?? string.Empty,
|
||||
Relationship = rel.Relationship ?? string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (v2.ListDetails?.Source != null)
|
||||
{
|
||||
foreach (var src in v2.ListDetails.Source)
|
||||
{
|
||||
result.Sources.Add(new CblSource
|
||||
{
|
||||
Name = src.Name ?? string.Empty,
|
||||
Url = src.Url ?? string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (v2.IssueList != null)
|
||||
{
|
||||
for (var i = 0; i < v2.IssueList.Count; i++)
|
||||
{
|
||||
var issue = v2.IssueList[i];
|
||||
var item = new ParsedCblItem
|
||||
{
|
||||
Order = i,
|
||||
SeriesName = issue.SeriesName ?? string.Empty,
|
||||
Number = issue.IssueNumber ?? string.Empty,
|
||||
Volume = issue.SeriesStartYear?.ToString(CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
Year = ExtractYear(issue.IssueCoverDate),
|
||||
CoverDate = issue.IssueCoverDate ?? string.Empty,
|
||||
IssueType = MapIssueType(issue.IssueType),
|
||||
};
|
||||
|
||||
if (issue.Id != null)
|
||||
{
|
||||
foreach (var id in issue.Id)
|
||||
{
|
||||
item.ExternalIds.Add(new CblExternalId
|
||||
{
|
||||
Provider = MapProviderName(id.Name),
|
||||
SeriesId = id.Series ?? string.Empty,
|
||||
IssueId = id.Issue ?? string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.Items.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static CblExternalDbProvider MapProviderName(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return CblExternalDbProvider.Unknown;
|
||||
|
||||
return name.ToLowerInvariant() switch
|
||||
{
|
||||
"cv" or "comicvine" => CblExternalDbProvider.ComicVine,
|
||||
"metron" => CblExternalDbProvider.Metron,
|
||||
"gcd" or "grandcomicsdatabase" => CblExternalDbProvider.GrandComicsDatabase,
|
||||
_ => CblExternalDbProvider.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static CblListType MapListType(string? type)
|
||||
{
|
||||
if (string.IsNullOrEmpty(type)) return CblListType.Unknown;
|
||||
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"master" => CblListType.Master,
|
||||
"interuniversal" => CblListType.Interuniversal,
|
||||
"universal" => CblListType.Universal,
|
||||
"team" => CblListType.Team,
|
||||
"character" => CblListType.Character,
|
||||
"story" => CblListType.Story,
|
||||
_ => CblListType.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static CblIssueType MapIssueType(string type)
|
||||
{
|
||||
if (string.IsNullOrEmpty(type)) return CblIssueType.Unknown;
|
||||
|
||||
return type.ToLowerInvariant() switch
|
||||
{
|
||||
"event-core" => CblIssueType.EventCore,
|
||||
"event-tie-in" => CblIssueType.EventTieIn,
|
||||
"event-one-shot" => CblIssueType.EventOneShot,
|
||||
"ongoing" => CblIssueType.Ongoing,
|
||||
_ => CblIssueType.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static string ExtractYear(string coverDate)
|
||||
{
|
||||
if (string.IsNullOrEmpty(coverDate)) return string.Empty;
|
||||
|
||||
// Expected format: "YYYY-MM-DD"
|
||||
var dashIndex = coverDate.IndexOf('-');
|
||||
return dashIndex > 0 ? coverDate[..dashIndex] : coverDate;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,10 +16,6 @@
|
||||
<PackageReference Include="CsvHelper" Version="33.1.0" />
|
||||
<PackageReference Include="MailKit" Version="4.15.1" />
|
||||
<PackageReference Include="Markdig" Version="1.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.3">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
|
||||
<PackageReference Include="Docnet.Core" Version="2.6.0" />
|
||||
<PackageReference Include="EasyCaching.InMemory" Version="1.9.2" />
|
||||
|
||||
@@ -312,7 +312,7 @@ public class LocalizationService : ILocalizationService
|
||||
{
|
||||
try
|
||||
{
|
||||
var jsonObject = System.Text.Json.JsonDocument.Parse(fileContent);
|
||||
var jsonObject = JsonDocument.Parse(fileContent);
|
||||
|
||||
int totalKeys = 0;
|
||||
int nonEmptyValues = 0;
|
||||
@@ -355,7 +355,7 @@ public class LocalizationService : ILocalizationService
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
if (property.Value.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
if (property.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
totalKeys++;
|
||||
var value = property.Value.GetString();
|
||||
@@ -371,7 +371,7 @@ public class LocalizationService : ILocalizationService
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
@@ -380,23 +380,23 @@ public class LocalizationService : ILocalizationService
|
||||
}
|
||||
}
|
||||
|
||||
private void CountEntries(System.Text.Json.JsonElement element, ref int total, ref int translated)
|
||||
private void CountEntries(JsonElement element, ref int total, ref int translated)
|
||||
{
|
||||
if (element.ValueKind == System.Text.Json.JsonValueKind.Object)
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var property in element.EnumerateObject())
|
||||
{
|
||||
CountEntries(property.Value, ref total, ref translated);
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == System.Text.Json.JsonValueKind.Array)
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
CountEntries(item, ref total, ref translated);
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == System.Text.Json.JsonValueKind.String)
|
||||
else if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
total++;
|
||||
string value = element.GetString();
|
||||
|
||||
@@ -10,6 +10,7 @@ using Kavita.API.Database;
|
||||
using Kavita.API.Errors;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.DTOs;
|
||||
using Kavita.Models.DTOs.Filtering;
|
||||
|
||||
@@ -9,6 +9,7 @@ using Flurl.Http;
|
||||
using Hangfire;
|
||||
using Kavita.API.Database;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services.Helpers;
|
||||
using Kavita.API.Services.Metadata;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.SignalR;
|
||||
@@ -177,8 +178,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
SeriesIncludes.Metadata | SeriesIncludes.ExternalMetadata | SeriesIncludes.Library, ct);
|
||||
if (series == null) return [];
|
||||
|
||||
var potentialAnilistId = ScrobblingHelper.ExtractId<int?>(dto.Query, ScrobblingService.AniListWeblinkWebsite);
|
||||
var potentialMalId = ScrobblingHelper.ExtractId<long?>(dto.Query, ScrobblingService.MalWeblinkWebsite);
|
||||
var potentialAnilistId = WeblinkParser.GetAniListId(dto.Query);
|
||||
var potentialMalId = WeblinkParser.GetMalId(dto.Query);
|
||||
|
||||
var format = series.Library.Type.ConvertToPlusMediaFormat(series.Format);
|
||||
var otherNames = ExtractAlternativeNames(series);
|
||||
@@ -412,7 +413,6 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
return _defaultReturn;
|
||||
}
|
||||
|
||||
|
||||
// Clear out existing results
|
||||
var externalSeriesMetadata = await GetOrCreateExternalSeriesMetadataForSeries(seriesId, series);
|
||||
_unitOfWork.ExternalSeriesMetadataRepository.Remove(externalSeriesMetadata.ExternalReviews);
|
||||
@@ -547,6 +547,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
madeModification = UpdateReleaseYear(series, settings, externalMetadata) || madeModification;
|
||||
madeModification = UpdateLocalizedName(series, settings, externalMetadata) || madeModification;
|
||||
madeModification = await UpdatePublicationStatus(series, settings, externalMetadata) || madeModification;
|
||||
madeModification = UpdateExternalIds(series, settings, externalMetadata) || madeModification;
|
||||
|
||||
// Apply field mappings
|
||||
GenerateGenreAndTagLists(externalMetadata, settings, ref processedTags, ref processedGenres);
|
||||
@@ -566,6 +567,8 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
madeModification = await UpdateChapters(series, settings, externalMetadata) || madeModification;
|
||||
|
||||
|
||||
|
||||
return madeModification;
|
||||
}
|
||||
|
||||
@@ -776,7 +779,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingHelper.ExtractId<int>(w.Url, ScrobblingService.AniListCharacterWebsite),
|
||||
AniListId = WeblinkParser.GetAniListCharacterId(w.Url),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
.Concat(series.Metadata.People
|
||||
@@ -818,7 +821,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
|
||||
foreach (var character in externalCharacters)
|
||||
{
|
||||
var aniListId = ScrobblingHelper.ExtractId<int>(character.Url, ScrobblingService.AniListCharacterWebsite);
|
||||
var aniListId = WeblinkParser.GetAniListCharacterId(character.Url);
|
||||
if (aniListId <= 0) continue;
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId);
|
||||
if (person != null && !string.IsNullOrEmpty(character.ImageUrl) && string.IsNullOrEmpty(person.CoverImage))
|
||||
@@ -857,7 +860,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingHelper.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
AniListId = WeblinkParser.GetAniListStaffId(w.Url),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
.Concat(series.Metadata.People
|
||||
@@ -914,7 +917,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
.Select(w => new PersonDto()
|
||||
{
|
||||
Name = w.Name.Trim(),
|
||||
AniListId = ScrobblingHelper.ExtractId<int>(w.Url, ScrobblingService.AniListStaffWebsite),
|
||||
AniListId = WeblinkParser.GetAniListStaffId(w.Url),
|
||||
Description = StringHelper.CorrectUrls(StringHelper.RemoveSourceInDescription(StringHelper.SquashBreaklines(w.Description))),
|
||||
})
|
||||
.Concat(series.Metadata.People
|
||||
@@ -1093,6 +1096,24 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool UpdateExternalIds(Series series, MetadataSettingsDto _, ExternalSeriesDetailDto externalMetadata)
|
||||
{
|
||||
var madeModification = false;
|
||||
if (externalMetadata.AniListId is > 0)
|
||||
{
|
||||
series.AniListId = externalMetadata.AniListId.Value;
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
if (externalMetadata.MALId is > 0)
|
||||
{
|
||||
series.MalId = externalMetadata.MALId.Value;
|
||||
madeModification = true;
|
||||
}
|
||||
|
||||
return madeModification;
|
||||
}
|
||||
|
||||
|
||||
private async Task<bool> UpdateChapters(Series series, MetadataSettingsDto settings,
|
||||
ExternalSeriesDetailDto externalMetadata)
|
||||
@@ -1110,7 +1131,7 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
externalMetadata.ChapterDtos,
|
||||
chapter => chapter.Range,
|
||||
dto => dto.IssueNumber,
|
||||
(chapter, dto) => (chapter, dto) // Create a tuple of matched pairs
|
||||
(chapter, dto) => (chapter, dto)
|
||||
)
|
||||
.ToList();
|
||||
|
||||
@@ -1540,9 +1561,9 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
foreach (var staff in people)
|
||||
{
|
||||
var aniListId = ScrobblingHelper.ExtractId<int?>(staff.Url, ScrobblingService.AniListStaffWebsite);
|
||||
if (aniListId is null or <= 0) continue;
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId.Value);
|
||||
var aniListId = WeblinkParser.GetAniListStaffId(staff.Url);
|
||||
if (aniListId <= 0) continue;
|
||||
var person = await _unitOfWork.PersonRepository.GetPersonByAniListId(aniListId);
|
||||
if (person == null || string.IsNullOrEmpty(staff.ImageUrl) ||
|
||||
!string.IsNullOrEmpty(person.CoverImage) || staff.ImageUrl.EndsWith("default.jpg")) continue;
|
||||
|
||||
@@ -1864,11 +1885,11 @@ public class ExternalMetadataService : IExternalMetadataService
|
||||
{
|
||||
if (payload.AniListId <= 0)
|
||||
{
|
||||
payload.AniListId = ScrobblingHelper.ExtractId<int>(series.Metadata.WebLinks, ScrobblingService.AniListWeblinkWebsite);
|
||||
payload.AniListId = WeblinkParser.GetAniListId(series.Metadata.WebLinks);
|
||||
}
|
||||
if (payload.MalId <= 0)
|
||||
{
|
||||
payload.MalId = ScrobblingHelper.ExtractId<long>(series.Metadata.WebLinks, ScrobblingService.MalWeblinkWebsite);
|
||||
payload.MalId = WeblinkParser.GetMalId(series.Metadata.WebLinks);
|
||||
}
|
||||
payload.SeriesName = series.Name;
|
||||
payload.LocalizedSeriesName = series.LocalizedName;
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Xml;
|
||||
using System.Xml.Serialization;
|
||||
using Kavita.API.Database;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V2;
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Kavita.Services.ReadingLists;
|
||||
|
||||
public interface ICblExportService
|
||||
{
|
||||
/// <summary>
|
||||
/// Exports the reading list to a temp file on disk.
|
||||
/// </summary>
|
||||
/// <remarks>Will overwrite existing files</remarks>
|
||||
/// <param name="readingListId"></param>
|
||||
/// <param name="userId"></param>
|
||||
/// <param name="asV2">Export as CBLv2 (JSON)</param>
|
||||
/// <returns>Full file path of the exported file, or null if reading list not found</returns>
|
||||
Task<string?> ExportReadingList(int readingListId, int userId, bool asV2 = false);
|
||||
}
|
||||
|
||||
public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<CblExportService> logger) : ICblExportService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> ExportReadingList(int readingListId, int userId, bool asV2 = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
var readingList = await unitOfWork.DataContext.ReadingList
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(rl => rl.Id == readingListId);
|
||||
|
||||
if (readingList == null) return null;
|
||||
|
||||
var items = await unitOfWork.DataContext.ReadingListItem
|
||||
.AsNoTracking()
|
||||
.Where(rli => rli.ReadingListId == readingListId)
|
||||
.OrderBy(rli => rli.Order)
|
||||
.Include(rli => rli.Chapter)
|
||||
.Include(rli => rli.Volume)
|
||||
.Include(rli => rli.Series)
|
||||
.ThenInclude(s => s.Metadata)
|
||||
.ThenInclude(m => m.People)
|
||||
.ThenInclude(smp => smp.Person)
|
||||
.ToListAsync();
|
||||
|
||||
var outputDir = Path.Combine(directoryService.TempDirectory, userId.ToString(), "cbl-export", $"{readingListId}");
|
||||
Directory.CreateDirectory(outputDir);
|
||||
|
||||
var sanitizedName = SanitizeFileName(readingList.Title);
|
||||
|
||||
if (asV2)
|
||||
{
|
||||
var jsonFileName = $"{sanitizedName}.json";
|
||||
var jsonFilePath = Path.Combine(outputDir, jsonFileName);
|
||||
|
||||
var v2 = BuildCblV2Root(readingList, items);
|
||||
SerializeV2(v2, jsonFilePath);
|
||||
|
||||
return jsonFilePath;
|
||||
}
|
||||
|
||||
var cblFileName = $"{sanitizedName}.cbl";
|
||||
var cblFilePath = Path.Combine(outputDir, cblFileName);
|
||||
|
||||
var cbl = BuildCblReadingList(readingList, items);
|
||||
SerializeV1(cbl, cblFilePath);
|
||||
|
||||
return cblFilePath;
|
||||
} catch (Exception e)
|
||||
{
|
||||
logger.LogError(e, "Error while exporting reading list: {ReadingListId}", readingListId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static CblReadingList BuildCblReadingList(ReadingList readingList, IList<ReadingListItem> items)
|
||||
{
|
||||
var books = new List<CblBook>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
var year = item.Chapter.ReleaseDate != DateTime.MinValue
|
||||
? item.Chapter.ReleaseDate.Year.ToString()
|
||||
: string.Empty;
|
||||
|
||||
books.Add(new CblBook
|
||||
{
|
||||
Series = item.Series.Name,
|
||||
Number = item.Chapter.Range, // Range can leak internal encodings. Need to understand how to map this.
|
||||
Volume = item.Volume.Name, // TODO: If the library is Comic type, we can try and parse from Kavita Series first. Need to test with real user files
|
||||
Year = year,
|
||||
Format = item.Chapter.IsSpecial ? "Annual" : string.Empty, // TODO: Confirm with CBL Group on how to handle Format
|
||||
FileType = MapMangaFormatToFileType(item.Series.Format),
|
||||
Database = null, // TODO: If we have ComicVine metadata id in Chapter, populate this
|
||||
});
|
||||
}
|
||||
|
||||
return new CblReadingList
|
||||
{
|
||||
Name = readingList.Title,
|
||||
Summary = readingList.Summary ?? string.Empty,
|
||||
StartYear = readingList.StartingYear,
|
||||
StartMonth = readingList.StartingMonth,
|
||||
EndYear = readingList.EndingYear,
|
||||
EndMonth = readingList.EndingMonth,
|
||||
Books = new CblBooks { Book = books },
|
||||
};
|
||||
}
|
||||
|
||||
public static void SerializeV1(CblReadingList cbl, string filePath)
|
||||
{
|
||||
var serializer = new XmlSerializer(typeof(CblReadingList));
|
||||
var settings = new XmlWriterSettings
|
||||
{
|
||||
Indent = true,
|
||||
Encoding = System.Text.Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var stream = File.Create(filePath);
|
||||
using var writer = XmlWriter.Create(stream, settings);
|
||||
serializer.Serialize(writer, cbl);
|
||||
}
|
||||
|
||||
public static CblV2Root BuildCblV2Root(ReadingList readingList, IList<ReadingListItem> items)
|
||||
{
|
||||
var publisher = GetMostCommonPerson(items, PersonRole.Publisher);
|
||||
var imprint = GetMostCommonPerson(items, PersonRole.Imprint);
|
||||
|
||||
var issues = new List<CblV2Issue>();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var coverDate = item.Chapter.ReleaseDate != DateTime.MinValue
|
||||
? item.Chapter.ReleaseDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)
|
||||
: string.Empty;
|
||||
|
||||
var seriesStartYear = item.Series.Metadata?.ReleaseYear is > 0
|
||||
? item.Series.Metadata.ReleaseYear
|
||||
: (int?)null;
|
||||
|
||||
issues.Add(new CblV2Issue
|
||||
{
|
||||
SeriesName = item.Series.Name,
|
||||
SeriesStartYear = seriesStartYear,
|
||||
IssueNumber = item.Chapter.Range,
|
||||
IssueCoverDate = coverDate,
|
||||
IssueType = string.Empty,
|
||||
Id = null, // TODO: When we expand Chapter-level external metadata, create this
|
||||
});
|
||||
}
|
||||
|
||||
return new CblV2Root
|
||||
{
|
||||
FileDetails = new CblV2FileDetails
|
||||
{
|
||||
UUID = Guid.NewGuid().ToString(),
|
||||
Version = 1.0,
|
||||
},
|
||||
ListDetails = new CblV2ListDetails
|
||||
{
|
||||
Name = readingList.Title,
|
||||
Description = readingList.Summary ?? string.Empty,
|
||||
StartYear = readingList.StartingYear > 0 ? readingList.StartingYear : null,
|
||||
EndYear = readingList.EndingYear > 0 ? readingList.EndingYear : null,
|
||||
Publisher = publisher ?? string.Empty,
|
||||
Imprint = imprint ?? string.Empty,
|
||||
Type = string.Empty,
|
||||
Tags = [],
|
||||
CoverImageURLs = [],
|
||||
Relationships = [],
|
||||
Source = [],
|
||||
},
|
||||
IssueList = issues,
|
||||
Notes = string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public static void SerializeV2(CblV2Root root, string filePath)
|
||||
{
|
||||
var options = new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(root, options);
|
||||
File.WriteAllText(filePath, json);
|
||||
}
|
||||
|
||||
public static string MapMangaFormatToFileType(MangaFormat format)
|
||||
{
|
||||
return format switch
|
||||
{
|
||||
MangaFormat.Archive => "cbz",
|
||||
MangaFormat.Epub => "epub",
|
||||
MangaFormat.Pdf => "pdf",
|
||||
MangaFormat.Image => "image",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public static string? GetMostCommonPerson(IList<ReadingListItem> items, PersonRole role)
|
||||
{
|
||||
return items
|
||||
.Where(i => i.Series?.Metadata?.People != null)
|
||||
.SelectMany(i => i.Series.Metadata.People)
|
||||
.Where(p => p.Role == role && p.Person != null)
|
||||
.GroupBy(p => p.Person.Name)
|
||||
.OrderByDescending(g => g.Count())
|
||||
.Select(g => g.Key)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string name)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Concat(name.Select(c => invalid.Contains(c) ? '_' : c));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading.Tasks;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
|
||||
namespace Kavita.Services.ReadingLists;
|
||||
|
||||
public class CblImporterService : ICblImportService
|
||||
{
|
||||
public Task ValidateList(int userId, string filePath, CblImportOptions options)
|
||||
{
|
||||
|
||||
throw new System.NotImplementedException();
|
||||
}
|
||||
|
||||
public Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions)
|
||||
{
|
||||
throw new System.NotImplementedException();
|
||||
}
|
||||
|
||||
public Task SyncReadingList(int userId, int readingListId)
|
||||
{
|
||||
throw new System.NotImplementedException();
|
||||
}
|
||||
}
|
||||
+2
@@ -10,6 +10,7 @@ using Kavita.API.Database;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Extensions;
|
||||
@@ -17,6 +18,7 @@ using Kavita.Common.Helpers;
|
||||
using Kavita.Models.Builders;
|
||||
using Kavita.Models.DTOs.ReadingLists;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL;
|
||||
using Kavita.Models.DTOs.ReadingLists.CBL.V1;
|
||||
using Kavita.Models.DTOs.SignalR;
|
||||
using Kavita.Models.Entities;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Metadata;
|
||||
using Kavita.Models.Parser;
|
||||
@@ -17,9 +18,9 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
||||
{
|
||||
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
// TODO: Potential Bug: This will return null, but on Image libraries, if all images, we would want to include this.
|
||||
if (type != LibraryType.Image && Scanner.Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null;
|
||||
if (type != LibraryType.Image && Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null;
|
||||
|
||||
if (Scanner.Parser.IsImage(filePath))
|
||||
if (Parser.IsImage(filePath))
|
||||
{
|
||||
return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, enableMetadata, comicInfo);
|
||||
}
|
||||
@@ -27,44 +28,45 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
||||
var ret = new ParserInfo()
|
||||
{
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Scanner.Parser.ParseFormat(filePath),
|
||||
Title = Scanner.Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Scanner.Parser.NormalizePath(filePath),
|
||||
Series = Scanner.Parser.ParseSeries(fileName, type),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
Title = Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Parser.NormalizePath(filePath),
|
||||
Series = Parser.ParseSeries(fileName, type),
|
||||
ComicInfo = comicInfo,
|
||||
Chapters = Scanner.Parser.ParseChapter(fileName, type),
|
||||
Volumes = Scanner.Parser.ParseVolume(fileName, type),
|
||||
Chapters = Parser.ParseChapter(fileName, type),
|
||||
};
|
||||
|
||||
if (ret.Series == string.Empty || Scanner.Parser.IsImage(filePath))
|
||||
ParseExternalIdsFromNotesAndWeblinks(ret);
|
||||
|
||||
if (ret.Series == string.Empty || Parser.IsImage(filePath))
|
||||
{
|
||||
// Try to parse information out of each folder all the way to rootPath
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
var edition = Scanner.Parser.ParseEdition(fileName);
|
||||
var edition = Parser.ParseEdition(fileName);
|
||||
if (!string.IsNullOrEmpty(edition))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
|
||||
ret.Edition = edition;
|
||||
}
|
||||
|
||||
var isSpecial = Scanner.Parser.IsSpecial(fileName, type);
|
||||
var isSpecial = Parser.IsSpecial(fileName, type);
|
||||
// We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that
|
||||
// could cause a problem as Omake is a special term, but there is valid volume/chapter information.
|
||||
if (Scanner.Parser.IsDefaultChapter(ret.Chapters) && Scanner.Parser.IsLooseLeafVolume(ret.Volumes) && isSpecial)
|
||||
if (Parser.IsDefaultChapter(ret.Chapters) && Parser.IsLooseLeafVolume(ret.Volumes) && isSpecial)
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret); // NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder
|
||||
}
|
||||
|
||||
// If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name
|
||||
if (Scanner.Parser.HasSpecialMarker(fileName))
|
||||
if (Parser.HasSpecialMarker(fileName))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.SpecialIndex = Scanner.Parser.ParseSpecialIndex(fileName);
|
||||
ret.Chapters = Scanner.Parser.DefaultChapter;
|
||||
ret.Volumes = Scanner.Parser.SpecialVolume;
|
||||
ret.SpecialIndex = Parser.ParseSpecialIndex(fileName);
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Volumes = Parser.SpecialVolume;
|
||||
|
||||
// NOTE: This uses rootPath. LibraryRoot works better for manga, but it's not always that way.
|
||||
// It might be worth writing some logic if the file is a special, to take the folder above the Specials/
|
||||
@@ -81,22 +83,22 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
||||
(fileDirectory.EndsWith("Specials", StringComparison.OrdinalIgnoreCase) ||
|
||||
fileDirectory.EndsWith("Specials/", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(Directory.GetParent(fileDirectory)?.Name ?? string.Empty);
|
||||
ret.Series = Parser.CleanTitle(Directory.GetParent(fileDirectory)?.Name ?? string.Empty);
|
||||
}
|
||||
else
|
||||
{
|
||||
ParseFromFallbackFolders(filePath, tempRootPath, type, ref ret);
|
||||
}
|
||||
ret.Title = Scanner.Parser.CleanSpecialTitle(fileName);
|
||||
ret.Title = Parser.CleanSpecialTitle(fileName);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(ret.Series))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
}
|
||||
|
||||
// Pdfs may have .pdf in the series name, remove that
|
||||
if (Scanner.Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
{
|
||||
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
|
||||
}
|
||||
@@ -109,7 +111,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
||||
|
||||
|
||||
|
||||
if (Scanner.Parser.IsLooseLeafVolume(ret.Volumes) && Scanner.Parser.IsDefaultChapter(ret.Chapters))
|
||||
if (Parser.IsLooseLeafVolume(ret.Volumes) && Parser.IsDefaultChapter(ret.Chapters))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
}
|
||||
@@ -117,7 +119,7 @@ public class BasicParser(IDirectoryService directoryService, IDefaultParser imag
|
||||
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
|
||||
if (ret.IsSpecial)
|
||||
{
|
||||
ret.Volumes = Scanner.Parser.SpecialVolume;
|
||||
ret.Volumes = Parser.SpecialVolume;
|
||||
}
|
||||
|
||||
return ret.Series == string.Empty ? null : ret;
|
||||
|
||||
@@ -24,11 +24,11 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
{
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = MangaFormat.Epub,
|
||||
Title = Scanner.Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Scanner.Parser.NormalizePath(filePath),
|
||||
Series = Scanner.Parser.ParseSeries(fileName, type),
|
||||
Chapters = Scanner.Parser.ParseChapter(fileName, type),
|
||||
Volumes = Scanner.Parser.ParseVolume(fileName, type),
|
||||
Title = Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Parser.NormalizePath(filePath),
|
||||
Series = Parser.ParseSeries(fileName, type),
|
||||
Chapters = Parser.ParseChapter(fileName, type),
|
||||
Volumes = Parser.ParseVolume(fileName, type),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,19 +41,19 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
}
|
||||
|
||||
// This catches when original library type is Manga/Comic and when parsing with non
|
||||
if (!Scanner.Parser.IsLooseLeafVolume(Scanner.Parser.ParseVolume(info.Series, type)))
|
||||
if (!Parser.IsLooseLeafVolume(Parser.ParseVolume(info.Series, type)))
|
||||
{
|
||||
var parsedVolumeFromTitle = Scanner.Parser.ParseVolume(info.Title, type);
|
||||
var parsedVolumeFromSeries = Scanner.Parser.ParseVolume(info.Series, type);
|
||||
var parsedVolumeFromTitle = Parser.ParseVolume(info.Title, type);
|
||||
var parsedVolumeFromSeries = Parser.ParseVolume(info.Series, type);
|
||||
|
||||
var hasVolumeInTitle = !Scanner.Parser.IsLooseLeafVolume(parsedVolumeFromTitle);
|
||||
var hasVolumeInSeries = !Scanner.Parser.IsLooseLeafVolume(parsedVolumeFromSeries);
|
||||
var hasVolumeInTitle = !Parser.IsLooseLeafVolume(parsedVolumeFromTitle);
|
||||
var hasVolumeInSeries = !Parser.IsLooseLeafVolume(parsedVolumeFromSeries);
|
||||
|
||||
if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series)))
|
||||
{
|
||||
// NOTE: I'm not sure the comment is true. I've never seen this triggered
|
||||
// This is likely a light novel for which we can set series from parsed title
|
||||
info.Series = Scanner.Parser.ParseSeries(info.Title, type);
|
||||
info.Series = Parser.ParseSeries(info.Title, type);
|
||||
info.Volumes = parsedVolumeFromTitle;
|
||||
}
|
||||
else
|
||||
@@ -61,7 +61,7 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, enableMetadata, comicInfo);
|
||||
info.Merge(info2);
|
||||
|
||||
if (hasVolumeInSeries && info2 != null && Scanner.Parser.IsLooseLeafVolume(Scanner.Parser.ParseVolume(info2.Series, type)))
|
||||
if (hasVolumeInSeries && info2 != null && Parser.IsLooseLeafVolume(Parser.ParseVolume(info2.Series, type)))
|
||||
{
|
||||
// Override the Series name so it groups appropriately
|
||||
info.Series = info2.Series;
|
||||
@@ -80,6 +80,6 @@ public class BookParser(IDirectoryService directoryService, IBookService bookSer
|
||||
/// <returns></returns>
|
||||
public override bool IsApplicable(string filePath, LibraryType type)
|
||||
{
|
||||
return Scanner.Parser.IsEpub(filePath);
|
||||
return Parser.IsEpub(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,22 +27,24 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
|
||||
|
||||
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
|
||||
// Mylar often outputs cover.jpg, ignore it by default
|
||||
if (string.IsNullOrEmpty(fileName) || Scanner.Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null;
|
||||
if (string.IsNullOrEmpty(fileName) || Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null;
|
||||
|
||||
var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
|
||||
|
||||
var info = new ParserInfo()
|
||||
{
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Scanner.Parser.ParseFormat(filePath),
|
||||
Title = Scanner.Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Scanner.Parser.NormalizePath(filePath),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
Title = Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Parser.NormalizePath(filePath),
|
||||
Series = string.Empty,
|
||||
ComicInfo = comicInfo,
|
||||
Chapters = Scanner.Parser.ParseChapter(fileName, type),
|
||||
Volumes = Scanner.Parser.ParseVolume(fileName, type)
|
||||
Chapters = Parser.ParseChapter(fileName, type),
|
||||
Volumes = Parser.ParseVolume(fileName, type)
|
||||
};
|
||||
|
||||
ParseExternalIdsFromNotesAndWeblinks(info);
|
||||
|
||||
// See if we can formulate the name from the ComicInfo
|
||||
if (!string.IsNullOrEmpty(info.ComicInfo?.Series) && !string.IsNullOrEmpty(info.ComicInfo?.Volume))
|
||||
{
|
||||
@@ -57,30 +59,30 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
|
||||
{
|
||||
foreach (var directory in directories)
|
||||
{
|
||||
if (!Scanner.Parser.IsSeriesAndYear(directory)) continue;
|
||||
if (!Parser.IsSeriesAndYear(directory)) continue;
|
||||
info.Series = directory;
|
||||
info.Volumes = Scanner.Parser.ParseYear(directory);
|
||||
info.Volumes = Parser.ParseYear(directory);
|
||||
break;
|
||||
}
|
||||
|
||||
// When there was at least one directory and we failed to parse the series, this is the final fallback
|
||||
if (string.IsNullOrEmpty(info.Series))
|
||||
{
|
||||
info.Series = Scanner.Parser.CleanTitle(directories[0], true);
|
||||
info.Series = Parser.CleanTitle(directories[0], true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (Scanner.Parser.IsSeriesAndYear(directoryName))
|
||||
if (Parser.IsSeriesAndYear(directoryName))
|
||||
{
|
||||
info.Series = directoryName;
|
||||
info.Volumes = Scanner.Parser.ParseYear(directoryName);
|
||||
info.Volumes = Parser.ParseYear(directoryName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this is a Special/Annual
|
||||
info.IsSpecial = Scanner.Parser.IsSpecial(info.Filename, type) || Scanner.Parser.IsSpecial(info.ComicInfo?.Format, type);
|
||||
info.IsSpecial = Parser.IsSpecial(info.Filename, type) || Parser.IsSpecial(info.ComicInfo?.Format, type);
|
||||
|
||||
// Patch in other information from ComicInfo
|
||||
if (enableMetadata)
|
||||
@@ -90,7 +92,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
|
||||
|
||||
if (string.IsNullOrEmpty(info.Series))
|
||||
{
|
||||
info.Series = Scanner.Parser.CleanTitle(directoryName, true);
|
||||
info.Series = Parser.CleanTitle(directoryName, true);
|
||||
}
|
||||
|
||||
|
||||
@@ -123,10 +125,10 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser
|
||||
if (!string.IsNullOrEmpty(info.ComicInfo.Number))
|
||||
{
|
||||
info.Chapters = info.ComicInfo.Number;
|
||||
if (info.IsSpecial && !Scanner.Parser.IsDefaultChapter(info.Chapters))
|
||||
if (info.IsSpecial && !Parser.IsDefaultChapter(info.Chapters))
|
||||
{
|
||||
info.IsSpecial = false;
|
||||
info.Volumes = $"{Scanner.Parser.SpecialVolumeNumber}";
|
||||
info.Volumes = $"{Parser.SpecialVolumeNumber}";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Metadata;
|
||||
using Kavita.Models.Parser;
|
||||
@@ -40,17 +41,17 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
||||
public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret)
|
||||
{
|
||||
var fallbackFolders = directoryService.GetFoldersTillRoot(rootPath, filePath)
|
||||
.Where(f => !Scanner.Parser.IsSpecial(f, type))
|
||||
.Where(f => !Parser.IsSpecial(f, type))
|
||||
.ToList();
|
||||
|
||||
if (fallbackFolders.Count == 0)
|
||||
{
|
||||
var rootFolderName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
|
||||
var series = Scanner.Parser.ParseSeries(rootFolderName, type);
|
||||
var series = Parser.ParseSeries(rootFolderName, type);
|
||||
|
||||
if (string.IsNullOrEmpty(series))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(rootFolderName, type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(rootFolderName, type is LibraryType.Comic);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -65,18 +66,18 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
||||
{
|
||||
var folder = fallbackFolders[i];
|
||||
|
||||
var parsedVolume = Scanner.Parser.ParseVolume(folder, type);
|
||||
var parsedChapter = Scanner.Parser.ParseChapter(folder, type);
|
||||
var parsedVolume = Parser.ParseVolume(folder, type);
|
||||
var parsedChapter = Parser.ParseChapter(folder, type);
|
||||
|
||||
var isLooseLeafVolume = Scanner.Parser.IsLooseLeafVolume(parsedVolume);
|
||||
var isDefaultChapter = Scanner.Parser.IsDefaultChapter(parsedChapter);
|
||||
var isLooseLeafVolume = Parser.IsLooseLeafVolume(parsedVolume);
|
||||
var isDefaultChapter = Parser.IsDefaultChapter(parsedChapter);
|
||||
|
||||
if ((string.IsNullOrEmpty(ret.Volumes) || Scanner.Parser.IsLooseLeafVolume(ret.Volumes))
|
||||
if ((string.IsNullOrEmpty(ret.Volumes) || Parser.IsLooseLeafVolume(ret.Volumes))
|
||||
&& !string.IsNullOrEmpty(parsedVolume) && !isLooseLeafVolume)
|
||||
{
|
||||
ret.Volumes = parsedVolume;
|
||||
}
|
||||
if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Scanner.Parser.DefaultChapter))
|
||||
if ((string.IsNullOrEmpty(ret.Chapters) || ret.Chapters.Equals(Parser.DefaultChapter))
|
||||
&& !string.IsNullOrEmpty(parsedChapter) && !isDefaultChapter)
|
||||
{
|
||||
ret.Chapters = parsedChapter;
|
||||
@@ -85,11 +86,11 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
||||
// Generally users group in series folders. Let's try to parse series from the top folder
|
||||
if (!folder.Equals(ret.Series) && i == fallbackFolders.Count - 1)
|
||||
{
|
||||
var series = Scanner.Parser.ParseSeries(folder, type);
|
||||
var series = Parser.ParseSeries(folder, type);
|
||||
|
||||
if (string.IsNullOrEmpty(series))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(folder, type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(folder, type is LibraryType.Comic);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -123,11 +124,11 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
||||
info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Scanner.Parser.HasComicInfoSpecial(info.ComicInfo.Format))
|
||||
if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format))
|
||||
{
|
||||
info.IsSpecial = true;
|
||||
info.Chapters = Scanner.Parser.DefaultChapter;
|
||||
info.Volumes = Scanner.Parser.SpecialVolume;
|
||||
info.Chapters = Parser.DefaultChapter;
|
||||
info.Volumes = Parser.SpecialVolume;
|
||||
}
|
||||
|
||||
// Patch is SeriesSort from ComicInfo
|
||||
@@ -142,7 +143,37 @@ public abstract class DefaultParser(IDirectoryService directoryService) : IDefau
|
||||
|
||||
protected static bool IsEmptyOrDefault(string volumes, string chapters)
|
||||
{
|
||||
return (string.IsNullOrEmpty(chapters) || Scanner.Parser.IsDefaultChapter(chapters)) &&
|
||||
(string.IsNullOrEmpty(volumes) || Scanner.Parser.IsLooseLeafVolume(volumes));
|
||||
return (string.IsNullOrEmpty(chapters) || Parser.IsDefaultChapter(chapters)) &&
|
||||
(string.IsNullOrEmpty(volumes) || Parser.IsLooseLeafVolume(volumes));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to fill in as much information as possible from Notes then Weblinks fields
|
||||
/// for different metadata Ids
|
||||
/// </summary>
|
||||
/// <param name="info"></param>
|
||||
protected static void ParseExternalIdsFromNotesAndWeblinks(ParserInfo info)
|
||||
{
|
||||
var notes = info.ComicInfo?.Notes;
|
||||
var weblinks = info.ComicInfo?.Web;
|
||||
|
||||
info.AniListId = WeblinkParser.GetAniListId(weblinks);
|
||||
info.MalId = WeblinkParser.GetMalId(weblinks);
|
||||
|
||||
var comicvineId = Parser.ParseComicVineIdFromComicInfoNote(notes);
|
||||
var parsedCvWeblink = WeblinkParser.GetComicVineId(weblinks);
|
||||
info.ComicVineId = !string.IsNullOrEmpty(comicvineId)
|
||||
? comicvineId
|
||||
: parsedCvWeblink.Item1;
|
||||
if (parsedCvWeblink.Item2)
|
||||
{
|
||||
info.ComicVineSeriesId = parsedCvWeblink.Item1;
|
||||
}
|
||||
|
||||
var metronId = Parser.ParseMetronIdFromComicInfoNote(notes);
|
||||
info.MetronId = !string.IsNullOrEmpty(metronId)
|
||||
? long.Parse(metronId)
|
||||
: 0L;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
|
||||
var ret = new ParserInfo
|
||||
{
|
||||
Series = directoryName,
|
||||
Volumes = Scanner.Parser.LooseLeafVolume,
|
||||
Chapters = Scanner.Parser.DefaultChapter,
|
||||
Volumes = Parser.LooseLeafVolume,
|
||||
Chapters = Parser.DefaultChapter,
|
||||
ComicInfo = comicInfo,
|
||||
Format = MangaFormat.Image,
|
||||
Filename = Path.GetFileName(filePath),
|
||||
FullFilePath = Scanner.Parser.NormalizePath(filePath),
|
||||
FullFilePath = Parser.NormalizePath(filePath),
|
||||
Title = fileName,
|
||||
};
|
||||
ParseFromFallbackFolders(filePath, libraryRoot, LibraryType.Image, ref ret);
|
||||
@@ -31,13 +31,13 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
|
||||
if (IsEmptyOrDefault(ret.Volumes, ret.Chapters))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.Volumes = Scanner.Parser.SpecialVolume;
|
||||
ret.Volumes = Parser.SpecialVolume;
|
||||
}
|
||||
|
||||
// Override the series name, as fallback folders needs it to try and parse folder name
|
||||
if (string.IsNullOrEmpty(ret.Series) || ret.Series.Equals(directoryName))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(directoryName);
|
||||
ret.Series = Parser.CleanTitle(directoryName);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,6 @@ public class ImageParser(IDirectoryService directoryService) : DefaultParser(dir
|
||||
/// <returns></returns>
|
||||
public override bool IsApplicable(string filePath, LibraryType type)
|
||||
{
|
||||
return type == LibraryType.Image && Scanner.Parser.IsImage(filePath);
|
||||
return type == LibraryType.Image && Parser.IsImage(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ public class ParseScannedFiles
|
||||
Library library, bool forceCheck, GlobMatcher matcher, List<ScanResult> result, string fileExtensions)
|
||||
{
|
||||
var allDirectories = _directoryService.GetAllDirectories(folderPath, matcher)
|
||||
.Select(Scanner.Parser.NormalizePath)
|
||||
.Select(Parser.NormalizePath)
|
||||
.OrderByDescending(d => d.Length)
|
||||
.ToList();
|
||||
|
||||
@@ -247,10 +247,10 @@ public class ParseScannedFiles
|
||||
private async Task<IList<ScanResult>> ScanSingleDirectory(string folderPath, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library, bool forceCheck, List<ScanResult> result,
|
||||
string fileExtensions, GlobMatcher matcher)
|
||||
{
|
||||
var normalizedPath = Scanner.Parser.NormalizePath(folderPath);
|
||||
var normalizedPath = Parser.NormalizePath(folderPath);
|
||||
var libraryRoot =
|
||||
library.Folders.FirstOrDefault(f =>
|
||||
normalizedPath.Contains(Scanner.Parser.NormalizePath(f.Path)))?.Path ??
|
||||
normalizedPath.Contains(Parser.NormalizePath(f.Path)))?.Path ??
|
||||
folderPath;
|
||||
|
||||
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
|
||||
@@ -286,7 +286,7 @@ public class ParseScannedFiles
|
||||
return new ScanResult()
|
||||
{
|
||||
Files = files,
|
||||
Folder = Scanner.Parser.NormalizePath(folderPath),
|
||||
Folder = Parser.NormalizePath(folderPath),
|
||||
LibraryRoot = libraryRoot,
|
||||
HasChanged = hasChanged
|
||||
};
|
||||
@@ -637,7 +637,7 @@ public class ParseScannedFiles
|
||||
case 1:
|
||||
return seriesForLocalized[0];
|
||||
case <= 2:
|
||||
return seriesForLocalized.FirstOrDefault(s => !s.Equals(Scanner.Parser.Normalize(localizedSeries)));
|
||||
return seriesForLocalized.FirstOrDefault(s => !s.Equals(Parser.Normalize(localizedSeries)));
|
||||
default:
|
||||
_logger.LogError(
|
||||
"[ScannerService] Multiple series detected across scan results that contain localized series. " +
|
||||
@@ -692,7 +692,7 @@ public class ParseScannedFiles
|
||||
/// <param name="library"></param>
|
||||
private async Task ParseFiles(ScanResult result, IDictionary<string, IList<SeriesModified>> seriesPaths, Library library)
|
||||
{
|
||||
var normalizedFolder = Scanner.Parser.NormalizePath(result.Folder);
|
||||
var normalizedFolder = Parser.NormalizePath(result.Folder);
|
||||
|
||||
// If folder hasn't changed, generate fake ParserInfos
|
||||
if (!result.HasChanged)
|
||||
@@ -778,7 +778,7 @@ public class ParseScannedFiles
|
||||
if (specialTreatment)
|
||||
{
|
||||
chapters = infos
|
||||
.OrderByNatural(info => Scanner.Parser.RemoveExtensionIfSupported(info.Filename)!)
|
||||
.OrderByNatural(info => Parser.RemoveExtensionIfSupported(info.Filename)!)
|
||||
.ToList();
|
||||
|
||||
foreach (var chapter in chapters)
|
||||
@@ -800,7 +800,7 @@ public class ParseScannedFiles
|
||||
{
|
||||
// Use MinNumber in case there is a range, as otherwise sort order will cause it to be processed last
|
||||
var chapterNum =
|
||||
$"{Scanner.Parser.MinNumberFromRange(chapter.Chapters).ToString(CultureInfo.InvariantCulture)}";
|
||||
$"{Parser.MinNumberFromRange(chapter.Chapters).ToString(CultureInfo.InvariantCulture)}";
|
||||
if (float.TryParse(chapterNum, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsedChapter))
|
||||
{
|
||||
// Parsed successfully, use the numeric value
|
||||
|
||||
@@ -703,6 +703,22 @@ public static partial class Parser
|
||||
MatchOptions, RegexTimeout
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// ComicTagger pattern for ComicInfo.Notes field
|
||||
/// </summary>
|
||||
/// <remarks>Scraped metadata from ComicVine [CVDB734524]</remarks>
|
||||
private static readonly Regex ComicVineScrapperRegex = new Regex(
|
||||
@"ComicVine\s\[CVDB(?<Id>\d+)\]",
|
||||
MatchOptions, RegexTimeout);
|
||||
|
||||
/// <summary>
|
||||
/// Metron pattern for ComicInfo.Notes field
|
||||
/// </summary>
|
||||
/// <remarks>Tagged with MetronTagger-4.4.0 using info from Metron on 2025-12-24 12:32:18. [issue_id:156409]</remarks>
|
||||
private static readonly Regex MetronScrapperRegex = new Regex(
|
||||
@"MetronTagger-.*\[issue_id:(?<Id>\d+)\]",
|
||||
MatchOptions, RegexTimeout);
|
||||
|
||||
|
||||
|
||||
public static MangaFormat ParseFormat(string filePath)
|
||||
@@ -1175,7 +1191,7 @@ public static partial class Parser
|
||||
|
||||
public static string? ExtractFilename(string fileUrl)
|
||||
{
|
||||
var matches = Parser.CssImageUrlRegex.Matches(fileUrl);
|
||||
var matches = CssImageUrlRegex.Matches(fileUrl);
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
if (!match.Success) continue;
|
||||
@@ -1309,7 +1325,25 @@ public static partial class Parser
|
||||
public static bool IsLikelyValidAsin(string? asin)
|
||||
{
|
||||
if (string.IsNullOrEmpty(asin)) return false;
|
||||
return AsinRegex.Match(asin).Success;
|
||||
return AsinRegex.IsMatch(asin);
|
||||
}
|
||||
|
||||
public static string? ParseComicVineIdFromComicInfoNote(string? note)
|
||||
{
|
||||
if (string.IsNullOrEmpty(note)) return null;
|
||||
var match = ComicVineScrapperRegex.Match(note);
|
||||
if (!match.Success) return null;
|
||||
|
||||
return match.Groups["Id"].Value;
|
||||
}
|
||||
|
||||
public static string? ParseMetronIdFromComicInfoNote(string? note)
|
||||
{
|
||||
if (string.IsNullOrEmpty(note)) return null;
|
||||
var match = MetronScrapperRegex.Match(note);
|
||||
if (!match.Success) return null;
|
||||
|
||||
return match.Groups["Id"].Value;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,21 +14,21 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
var ret = new ParserInfo
|
||||
{
|
||||
Filename = Path.GetFileName(filePath),
|
||||
Format = Scanner.Parser.ParseFormat(filePath),
|
||||
Title = Scanner.Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Scanner.Parser.NormalizePath(filePath),
|
||||
Format = Parser.ParseFormat(filePath),
|
||||
Title = Parser.RemoveExtensionIfSupported(fileName)!,
|
||||
FullFilePath = Parser.NormalizePath(filePath),
|
||||
Series = string.Empty,
|
||||
ComicInfo = comicInfo,
|
||||
Chapters = Scanner.Parser.ParseChapter(fileName, type)
|
||||
Chapters = Parser.ParseChapter(fileName, type)
|
||||
};
|
||||
|
||||
if (type == LibraryType.Book)
|
||||
{
|
||||
ret.Chapters = Scanner.Parser.DefaultChapter;
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
}
|
||||
|
||||
ret.Series = Scanner.Parser.ParseSeries(fileName, type);
|
||||
ret.Volumes = Scanner.Parser.ParseVolume(fileName, type);
|
||||
ret.Series = Parser.ParseSeries(fileName, type);
|
||||
ret.Volumes = Parser.ParseVolume(fileName, type);
|
||||
|
||||
if (ret.Series == string.Empty)
|
||||
{
|
||||
@@ -36,17 +36,17 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
var edition = Scanner.Parser.ParseEdition(fileName);
|
||||
var edition = Parser.ParseEdition(fileName);
|
||||
if (!string.IsNullOrEmpty(edition))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
|
||||
ret.Edition = edition;
|
||||
}
|
||||
|
||||
var isSpecial = Scanner.Parser.IsSpecial(fileName, type);
|
||||
var isSpecial = Parser.IsSpecial(fileName, type);
|
||||
// We must ensure that we can only parse a special out. As some files will have v20 c171-180+Omake and that
|
||||
// could cause a problem as Omake is a special term, but there is valid volume/chapter information.
|
||||
if (Scanner.Parser.IsDefaultChapter(ret.Chapters) && Scanner.Parser.IsLooseLeafVolume(ret.Volumes) && isSpecial)
|
||||
if (Parser.IsDefaultChapter(ret.Chapters) && Parser.IsLooseLeafVolume(ret.Volumes) && isSpecial)
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
// NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder
|
||||
@@ -54,12 +54,12 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
}
|
||||
|
||||
// If we are a special with marker, we need to ensure we use the correct series name. we can do this by falling back to Folder name
|
||||
if (Scanner.Parser.HasSpecialMarker(fileName))
|
||||
if (Parser.HasSpecialMarker(fileName))
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.SpecialIndex = Scanner.Parser.ParseSpecialIndex(fileName);
|
||||
ret.Chapters = Scanner.Parser.DefaultChapter;
|
||||
ret.Volumes = Scanner.Parser.SpecialVolume;
|
||||
ret.SpecialIndex = Parser.ParseSpecialIndex(fileName);
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Volumes = Parser.SpecialVolume;
|
||||
|
||||
var tempRootPath = rootPath;
|
||||
if (rootPath.EndsWith("Specials") || rootPath.EndsWith("Specials/"))
|
||||
@@ -82,11 +82,11 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
}
|
||||
|
||||
|
||||
if (Scanner.Parser.IsDefaultChapter(ret.Chapters) && Scanner.Parser.IsLooseLeafVolume(ret.Volumes) && type == LibraryType.Book)
|
||||
if (Parser.IsDefaultChapter(ret.Chapters) && Parser.IsLooseLeafVolume(ret.Volumes) && type == LibraryType.Book)
|
||||
{
|
||||
ret.IsSpecial = true;
|
||||
ret.Chapters = Scanner.Parser.DefaultChapter;
|
||||
ret.Volumes = Scanner.Parser.SpecialVolume;
|
||||
ret.Chapters = Parser.DefaultChapter;
|
||||
ret.Volumes = Parser.SpecialVolume;
|
||||
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
|
||||
}
|
||||
|
||||
@@ -105,11 +105,11 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
|
||||
if (string.IsNullOrEmpty(ret.Series))
|
||||
{
|
||||
ret.Series = Scanner.Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
|
||||
}
|
||||
|
||||
// Pdfs may have .pdf in the series name, remove that
|
||||
if (Scanner.Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
|
||||
{
|
||||
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
|
||||
if (ret.IsSpecial)
|
||||
{
|
||||
ret.Volumes = $"{Scanner.Parser.SpecialVolumeNumber}";
|
||||
ret.Volumes = $"{Parser.SpecialVolumeNumber}";
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(ret.Series) ? null : ret;
|
||||
@@ -131,6 +131,6 @@ public class PdfParser(IDirectoryService directoryService) : DefaultParser(direc
|
||||
/// <returns></returns>
|
||||
public override bool IsApplicable(string filePath, LibraryType type)
|
||||
{
|
||||
return Scanner.Parser.IsPdf(filePath);
|
||||
return Parser.IsPdf(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ using Kavita.API.Services;
|
||||
using Kavita.API.Services.Helpers;
|
||||
using Kavita.API.Services.Plus;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.Scanner;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Extensions;
|
||||
using Kavita.Common.Helpers;
|
||||
using Kavita.Models.Builders;
|
||||
using Kavita.Models.DTOs.KavitaPlus.Metadata;
|
||||
using Kavita.Models.DTOs.SignalR;
|
||||
@@ -151,6 +153,13 @@ public class ProcessSeries(
|
||||
series.NormalizedLocalizedName = series.LocalizedName.ToNormalized();
|
||||
}
|
||||
|
||||
// Check if there is a comicvineSeriesId on file
|
||||
var comicVineSeriesIds = parsedInfos.Select(p => p.ComicVineSeriesId).Where(s => !string.IsNullOrEmpty(s)).Distinct().ToList();
|
||||
if (comicVineSeriesIds.Count == 1)
|
||||
{
|
||||
series.ComicVineId = comicVineSeriesIds[0];
|
||||
}
|
||||
|
||||
await UpdateSeriesMetadata(databasePeople, settings, series, library);
|
||||
|
||||
// Update series FolderPath here
|
||||
@@ -348,6 +357,9 @@ public class ProcessSeries(
|
||||
if (!string.IsNullOrEmpty(firstChapter?.WebLinks) && library.InheritWebLinksFromFirstChapter)
|
||||
{
|
||||
series.Metadata.WebLinks = firstChapter.WebLinks;
|
||||
series.AniListId = WeblinkParser.GetAniListId(series.Metadata.WebLinks) ?? 0;
|
||||
series.MalId = WeblinkParser.GetMalId(series.Metadata.WebLinks) ?? 0;
|
||||
series.ComicVineId = WeblinkParser.GetComicVineId(series.Metadata.WebLinks).Item1;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(firstChapter?.SeriesGroup) && library.ManageCollections)
|
||||
@@ -716,6 +728,13 @@ public class ProcessSeries(
|
||||
logger.LogError(ex, "There was some issue when updating chapter's metadata");
|
||||
}
|
||||
|
||||
// Try to patch in any External Metadata Ids we've seen during parsing
|
||||
chapter.AniListId = info.AniListId ?? 0;
|
||||
chapter.MalId = info.MalId ?? 0;
|
||||
chapter.MangaBakaId = info.MangaBakaId ?? 0;
|
||||
chapter.MetronId = info.MetronId ?? 0;
|
||||
chapter.ComicVineId = info.ComicVineId;
|
||||
chapter.HardcoverId = info.HardcoverId ?? 0;
|
||||
}
|
||||
|
||||
RemoveChapters(args.Volume, args.ParsedInfos);
|
||||
@@ -873,10 +892,6 @@ public class ProcessSeries(
|
||||
if (!string.IsNullOrEmpty(comicInfo.Web))
|
||||
{
|
||||
chapter.WebLinks = string.Join(",", comicInfo.Web.SplitBy(','));
|
||||
|
||||
// TODO: For each weblink, try to parse out some MetadataIds and store in the Chapter directly for matching (CBL)
|
||||
// var aniListId = ScrobblingHelper.GetAniListId(chapter.WebLinks);
|
||||
// var malId = ScrobblingHelper.GetMalId(chapter.WebLinks);
|
||||
}
|
||||
|
||||
if (!chapter.ISBNLocked && !string.IsNullOrEmpty(comicInfo.Isbn))
|
||||
|
||||
@@ -730,7 +730,8 @@ public class ScannerService(
|
||||
/// <param name="libraryName"></param>
|
||||
/// <param name="forceUpdate"></param>
|
||||
/// <returns>The total amount of processed files</returns>
|
||||
private async Task<long> DbMetadataTask(Channel<int> channel, MetadataSettingsDto settings, IList<IList<ParserInfo>> toProcess, int libraryId, string libraryName, bool forceUpdate)
|
||||
private async Task<long> DbMetadataTask(Channel<int> channel, MetadataSettingsDto settings,
|
||||
IList<IList<ParserInfo>> toProcess, int libraryId, string libraryName, bool forceUpdate)
|
||||
{
|
||||
var totalFiles = 0;
|
||||
var seriesLeftToProcess = toProcess.Count;
|
||||
@@ -748,7 +749,8 @@ public class ScannerService(
|
||||
var processSeries = scope.ServiceProvider.GetRequiredService<IProcessSeries>();
|
||||
|
||||
// Library needs to be returned from the used UnitOfWork
|
||||
var library = (await unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns))!;
|
||||
var library = (await unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId,
|
||||
LibraryIncludes.Folders | LibraryIncludes.FileTypes | LibraryIncludes.ExcludePatterns))!;
|
||||
|
||||
var seriesId = await processSeries.ProcessSeriesAsync(settings, pSeries, new ProcessSeriesArgs
|
||||
{
|
||||
|
||||
@@ -7,6 +7,7 @@ using Kavita.API.Database;
|
||||
using Kavita.API.Repositories;
|
||||
using Kavita.API.Services;
|
||||
using Kavita.API.Services.Reading;
|
||||
using Kavita.API.Services.ReadingLists;
|
||||
using Kavita.API.Services.SignalR;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.Extensions;
|
||||
|
||||
@@ -112,4 +112,8 @@ export enum Action {
|
||||
Export = 32,
|
||||
Like = 33,
|
||||
UnLike = 34,
|
||||
/** Export as CBLv1 */
|
||||
ExportAsV1 = 35,
|
||||
/** Export as CBLv2 */
|
||||
ExportAsV2 = 36,
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ import {IHasCast} from "./common/i-has-cast";
|
||||
import {IHasReadingTime} from "./common/i-has-reading-time";
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
import {IHasProgress} from "./common/i-has-progress";
|
||||
import {IHasMetadataIds} from "./common/i-has-metadata-ids";
|
||||
|
||||
export const LooseLeafOrDefaultNumber = -100000;
|
||||
export const SpecialVolumeNumber = 100000;
|
||||
|
||||
export interface Chapter extends IHasCast, IHasReadingTime, IHasCover, IHasProgress {
|
||||
export interface Chapter extends IHasCast, IHasReadingTime, IHasCover, IHasProgress, IHasMetadataIds {
|
||||
id: number;
|
||||
range: string;
|
||||
/**
|
||||
@@ -70,6 +71,13 @@ export interface Chapter extends IHasCast, IHasReadingTime, IHasCover, IHasProgr
|
||||
totalCount: number;
|
||||
totalReads: number;
|
||||
|
||||
aniListId: number;
|
||||
malId: number;
|
||||
hardcoverId: number;
|
||||
metronId: number;
|
||||
comicVineId: string | null;
|
||||
mangaBakaId: number;
|
||||
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface IHasMetadataIds {
|
||||
aniListId: number;
|
||||
malId: number;
|
||||
hardcoverId: number;
|
||||
metronId: number;
|
||||
comicVineId: string | null;
|
||||
mangaBakaId: number;
|
||||
}
|
||||
@@ -1,56 +1,56 @@
|
||||
import { Genre } from "./genre";
|
||||
import { AgeRating } from "./age-rating";
|
||||
import { PublicationStatus } from "./publication-status";
|
||||
import { Person } from "./person";
|
||||
import { Tag } from "../tag";
|
||||
import {Genre} from "./genre";
|
||||
import {AgeRating} from "./age-rating";
|
||||
import {PublicationStatus} from "./publication-status";
|
||||
import {Person} from "./person";
|
||||
import {Tag} from "../tag";
|
||||
import {IHasCast} from "../common/i-has-cast";
|
||||
|
||||
export interface SeriesMetadata extends IHasCast {
|
||||
seriesId: number;
|
||||
summary: string;
|
||||
seriesId: number;
|
||||
summary: string;
|
||||
|
||||
totalCount: number;
|
||||
maxCount: number;
|
||||
totalCount: number;
|
||||
maxCount: number;
|
||||
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
imprints: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
teams: Array<Person>;
|
||||
locations: Array<Person>;
|
||||
ageRating: AgeRating;
|
||||
releaseYear: number;
|
||||
language: string;
|
||||
publicationStatus: PublicationStatus;
|
||||
webLinks: string;
|
||||
genres: Array<Genre>;
|
||||
tags: Array<Tag>;
|
||||
writers: Array<Person>;
|
||||
coverArtists: Array<Person>;
|
||||
publishers: Array<Person>;
|
||||
characters: Array<Person>;
|
||||
pencillers: Array<Person>;
|
||||
inkers: Array<Person>;
|
||||
imprints: Array<Person>;
|
||||
colorists: Array<Person>;
|
||||
letterers: Array<Person>;
|
||||
editors: Array<Person>;
|
||||
translators: Array<Person>;
|
||||
teams: Array<Person>;
|
||||
locations: Array<Person>;
|
||||
ageRating: AgeRating;
|
||||
releaseYear: number;
|
||||
language: string;
|
||||
publicationStatus: PublicationStatus;
|
||||
webLinks: string;
|
||||
|
||||
summaryLocked: boolean;
|
||||
genresLocked: boolean;
|
||||
tagsLocked: boolean;
|
||||
writerLocked: boolean;
|
||||
coverArtistLocked: boolean;
|
||||
publisherLocked: boolean;
|
||||
characterLocked: boolean;
|
||||
pencillerLocked: boolean;
|
||||
inkerLocked: boolean;
|
||||
imprintLocked: boolean;
|
||||
coloristLocked: boolean;
|
||||
lettererLocked: boolean;
|
||||
editorLocked: boolean;
|
||||
translatorLocked: boolean;
|
||||
teamLocked: boolean;
|
||||
locationLocked: boolean;
|
||||
ageRatingLocked: boolean;
|
||||
releaseYearLocked: boolean;
|
||||
languageLocked: boolean;
|
||||
publicationStatusLocked: boolean;
|
||||
summaryLocked: boolean;
|
||||
genresLocked: boolean;
|
||||
tagsLocked: boolean;
|
||||
writerLocked: boolean;
|
||||
coverArtistLocked: boolean;
|
||||
publisherLocked: boolean;
|
||||
characterLocked: boolean;
|
||||
pencillerLocked: boolean;
|
||||
inkerLocked: boolean;
|
||||
imprintLocked: boolean;
|
||||
coloristLocked: boolean;
|
||||
lettererLocked: boolean;
|
||||
editorLocked: boolean;
|
||||
translatorLocked: boolean;
|
||||
teamLocked: boolean;
|
||||
locationLocked: boolean;
|
||||
ageRatingLocked: boolean;
|
||||
releaseYearLocked: boolean;
|
||||
languageLocked: boolean;
|
||||
publicationStatusLocked: boolean;
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ import {Volume} from './volume';
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
import {IHasReadingTime} from "./common/i-has-reading-time";
|
||||
import {IHasProgress} from "./common/i-has-progress";
|
||||
import {IHasMetadataIds} from "./common/i-has-metadata-ids";
|
||||
|
||||
export interface Series extends IHasCover, IHasReadingTime, IHasProgress {
|
||||
export interface Series extends IHasCover, IHasReadingTime, IHasProgress, IHasMetadataIds {
|
||||
id: number;
|
||||
name: string;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Represents a Tab in the system, use TabTitlePipe to transform to string **/
|
||||
export enum Tabs {
|
||||
Details = 'details-tab',
|
||||
Reviews = 'reviews-tab',
|
||||
Storyline = 'storyline-tab',
|
||||
Books = 'books-tab',
|
||||
Volumes = 'volumes-tab',
|
||||
Specials = 'specials-tab',
|
||||
Related = 'related-tab',
|
||||
General = 'general-tab',
|
||||
Folder = 'folder-tab',
|
||||
CoverImage = 'cover-tab',
|
||||
Advanced = 'advanced-tab',
|
||||
Tasks = 'tasks-tab',
|
||||
Recommendations = 'recommendations-tab',
|
||||
Info = 'info-tab',
|
||||
Tags = 'tags-tab',
|
||||
WebLinks = 'weblink-tab',
|
||||
People = 'people-tab',
|
||||
Metadata = 'metadata-tab',
|
||||
Series = 'series-tab',
|
||||
Account = 'account-tab',
|
||||
Preferences = 'preferences-tab',
|
||||
Theme = 'theme-tab',
|
||||
Devices = 'devices-tab',
|
||||
Stats = 'stats-tab',
|
||||
Scrobbling = 'scrobbling-tab',
|
||||
SmartFilters = 'smart-filters-tab',
|
||||
Annotations = 'annotations-tab',
|
||||
Overview = 'overview-tab',
|
||||
Management = 'management-tab',
|
||||
Activity = 'activity-tab',
|
||||
ExternalMetadataIds = 'external-ids-tab',
|
||||
Aliases = 'aliases-tab',
|
||||
Chapters = 'chapters-tab',
|
||||
|
||||
Dashboard = 'dashboard-tab',
|
||||
SideNav = 'sidenav-tab',
|
||||
ExternalSources = 'external-sources-tab',
|
||||
|
||||
ImageReader = "image-reader-tab",
|
||||
BookReader = "book-reader-tab",
|
||||
PdfReader = "pdf-reader-tab",
|
||||
|
||||
BookmarkImageTab = "bookmark-image-tab",
|
||||
BookmarkTextTab = "bookmark-text-tab",
|
||||
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {IHasMetadataIds} from "./common/i-has-metadata-ids";
|
||||
|
||||
export interface UpdateVolume extends IHasMetadataIds {
|
||||
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import {HourEstimateRange} from './series-detail/hour-estimate-range';
|
||||
import {IHasCover} from "./common/i-has-cover";
|
||||
import {IHasReadingTime} from "./common/i-has-reading-time";
|
||||
import {IHasProgress} from "./common/i-has-progress";
|
||||
import {IHasMetadataIds} from "./common/i-has-metadata-ids";
|
||||
|
||||
export interface Volume extends IHasCover, IHasReadingTime, IHasProgress {
|
||||
export interface Volume extends IHasCover, IHasReadingTime, IHasProgress, IHasMetadataIds {
|
||||
id: number;
|
||||
minNumber: number;
|
||||
maxNumber: number;
|
||||
@@ -27,4 +28,11 @@ export interface Volume extends IHasCover, IHasReadingTime, IHasProgress {
|
||||
coverImageLocked: boolean;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
|
||||
aniListId: number;
|
||||
malId: number;
|
||||
hardcoverId: number;
|
||||
metronId: number;
|
||||
comicVineId: string | null;
|
||||
mangaBakaId: number;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {inject, Pipe, PipeTransform} from '@angular/core';
|
||||
import {Tabs} from "../_models/tabs";
|
||||
import {TranslocoService} from "@jsverse/transloco";
|
||||
|
||||
@Pipe({
|
||||
name: 'tabTitle',
|
||||
pure: true,
|
||||
standalone: true
|
||||
})
|
||||
export class TabTitlePipe implements PipeTransform {
|
||||
private readonly translocoService = inject(TranslocoService);
|
||||
|
||||
transform(value: Tabs): string {
|
||||
return this.translocoService.translate('tabs.' + value);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -478,7 +478,7 @@ export class ActionFactoryService {
|
||||
{
|
||||
action: Action.RemoveFromWantToReadList,
|
||||
title: 'remove-from-want-to-read',
|
||||
description: 'remove-to-want-to-read-tooltip',
|
||||
description: 'remove-from-want-to-read-tooltip',
|
||||
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
@@ -980,6 +980,38 @@ export class ActionFactoryService {
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.Submenu,
|
||||
title: 'export',
|
||||
description: 'export-tooltip',
|
||||
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
|
||||
requiredRoles: [],
|
||||
children: [
|
||||
{
|
||||
action: Action.ExportAsV1,
|
||||
title: 'export-v1',
|
||||
description: 'export-v1-tooltip',
|
||||
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
action: Action.ExportAsV2,
|
||||
title: 'export-v2',
|
||||
description: 'export-v2-tooltip',
|
||||
|
||||
callback: this.dummyCallback,
|
||||
shouldRender: this.dummyShouldRender,
|
||||
requiredRoles: [],
|
||||
children: [],
|
||||
}
|
||||
],
|
||||
}
|
||||
];
|
||||
|
||||
this.personActions = [
|
||||
|
||||
@@ -672,6 +672,16 @@ export class ActionService {
|
||||
tap(() => this.toastr.success(translate('toasts.reading-list-unpromoted'))),
|
||||
map(() => this.fromAction(action, {...readingList, promoted: false}, 'update'))
|
||||
);
|
||||
|
||||
case Action.ExportAsV1:
|
||||
return this.downloadService.exportReadingList(readingList.id, readingList.title).pipe(
|
||||
map(() => this.fromAction(action, readingList, 'none'))
|
||||
);
|
||||
case Action.ExportAsV2:
|
||||
return this.downloadService.exportReadingList(readingList.id, readingList.title, true).pipe(
|
||||
map(() => this.fromAction(action, readingList, 'none'))
|
||||
);
|
||||
|
||||
default:
|
||||
return of(this.fromAction(action, readingList, 'none'));
|
||||
}
|
||||
|
||||
@@ -262,8 +262,8 @@ export class CardConfigFactory {
|
||||
if ([LibraryType.LightNovel || LibraryType.Book].includes(params.libraryType)) {
|
||||
return v.name;
|
||||
}
|
||||
if (v.hasOwnProperty('chapters') && v.chapters.length > 0 && v.chapters[0].titleName) {
|
||||
v.chapters[0].titleName
|
||||
if (v.hasOwnProperty('chapters') && v.chapters.length === 1 && v.chapters[0].titleName) {
|
||||
return v.chapters[0].titleName;
|
||||
}
|
||||
|
||||
return v.name;
|
||||
|
||||
@@ -135,5 +135,4 @@ export class ReadingListService {
|
||||
return this.httpClient.post(this.baseUrl + 'readinglist/delete-multiple', {readingListIds: listIds}, TextResonse);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user