CBL Export and External Metadata Ids (#4532)

This commit is contained in:
Joe Milazzo
2026-03-15 08:39:11 -05:00
committed by GitHub
parent 2505c6ba62
commit fefe35a2a5
145 changed files with 12820 additions and 3882 deletions
+4 -78
View File
@@ -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,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);
}
}
+152
View File
@@ -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));
}
}
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
+10 -1
View File
@@ -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; }
}
@@ -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; }
}
+10 -1
View File
@@ -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;
+11 -1
View File
@@ -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; }
}
+13 -2
View File
@@ -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
{
+15
View File
@@ -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; }
}
+10 -1
View File
@@ -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;
+14 -7
View File
@@ -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; }
}
-1
View File
@@ -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>
+12 -3
View File
@@ -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!;
+10 -1
View File
@@ -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!;
+30 -1
View File
@@ -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; }
}
+3 -4
View File
@@ -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;
}
}
}
+1
View File
@@ -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"
+7
View File
@@ -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;
@@ -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>
@@ -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>();
+231
View File
@@ -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;
}
}
-4
View File
@@ -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" />
+7 -7
View File
@@ -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();
+1
View File
@@ -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;
+34 -13
View File
@@ -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();
}
}
@@ -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;
+25 -23
View File
@@ -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;
+13 -13
View File
@@ -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);
}
}
+17 -15
View File
@@ -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}";
}
}
+47 -16
View File
@@ -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;
}
}
+6 -6
View File
@@ -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);
}
}
+8 -8
View File
@@ -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
+36 -2
View File
@@ -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;
}
+22 -22
View File
@@ -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);
}
}
+19 -4
View File
@@ -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))
+4 -2
View File
@@ -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
{
+1
View File
@@ -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,
}
+9 -1
View File
@@ -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;
}
+2 -1
View File
@@ -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;
/**
+48
View File
@@ -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",
}
+5
View File
@@ -0,0 +1,5 @@
import {IHasMetadataIds} from "./common/i-has-metadata-ids";
export interface UpdateVolume extends IHasMetadataIds {
}
+9 -1
View File
@@ -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;
}
+17
View File
@@ -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