Comic Rework (Part 1) (#2772)

This commit is contained in:
Joe Milazzo 2024-03-09 10:36:36 -07:00 committed by GitHub
parent 58c77b32b1
commit fc21073898
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
69 changed files with 5090 additions and 703 deletions

View file

@ -19,9 +19,8 @@ public class ParserInfoListExtensions
private readonly IDefaultParser _defaultParser;
public ParserInfoListExtensions()
{
_defaultParser =
new DefaultParser(new DirectoryService(Substitute.For<ILogger<DirectoryService>>(),
new MockFileSystem()));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem());
_defaultParser = new BasicParser(ds, new ImageParser(ds));
}
[Theory]
@ -43,7 +42,7 @@ public class ParserInfoListExtensions
{
infos.Add(_defaultParser.Parse(
Path.Join("E:/Manga/Cynthia the Mission/", filename),
"E:/Manga/"));
"E:/Manga/", "E:/Manga/", LibraryType.Manga));
}
var files = inputChapters.Select(s => new MangaFileBuilder(s, MangaFormat.Archive, 199).Build()).ToList();
@ -61,7 +60,7 @@ public class ParserInfoListExtensions
{
_defaultParser.Parse(
"E:/Manga/Cynthia the Mission/Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip",
"E:/Manga/")
"E:/Manga/", "E:/Manga/", LibraryType.Manga)
};
var files = new[] {@"E:\Manga\Cynthia the Mission\Cynthia The Mission The Special SP01 [Desudesu&Brolen].zip"}

View file

@ -0,0 +1,217 @@
using System.IO.Abstractions.TestingHelpers;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Parsers;
public class BasicParserTests
{
private readonly BasicParser _parser;
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
private const string RootDirectory = "C:/Books/";
public BasicParserTests()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Books/");
fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World - Volume 1 Chapter 2.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World - Chapter 3.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/Accel World Gaiden SP01.cbz", new MockFileData(""));
fileSystem.AddFile("C:/Books/Accel World/cover.png", new MockFileData(""));
fileSystem.AddFile("C:/Books/Batman/Batman #1.cbz", new MockFileData(""));
var ds = new DirectoryService(_dsLogger, fileSystem);
_parser = new BasicParser(ds, new ImageParser(ds));
}
#region Parse_Books
#endregion
#region Parse_Manga
/// <summary>
/// Tests that when there is a loose leaf cover in the manga library, that it is ignored
/// </summary>
[Fact]
public void Parse_MangaLibrary_JustCover_ShouldReturnNull()
{
var actual = _parser.Parse(@"C:/Books/Accel World/cover.png", "C:/Books/Accel World/",
RootDirectory, LibraryType.Manga, null);
Assert.Null(actual);
}
/// <summary>
/// Tests that when there is a loose leaf cover in the manga library, that it is ignored
/// </summary>
[Fact]
public void Parse_MangaLibrary_OtherImage_ShouldReturnNull()
{
var actual = _parser.Parse(@"C:/Books/Accel World/page 01.png", "C:/Books/Accel World/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
}
/// <summary>
/// Tests that when there is a volume and chapter in filename, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_VolumeAndChapterInFilename()
{
var actual = _parser.Parse("C:/Books/Mujaki no Rakuen/Mujaki no Rakuen Vol12 ch76.cbz", "C:/Books/Mujaki no Rakuen/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Mujaki no Rakuen", actual.Series);
Assert.Equal("12", actual.Volumes);
Assert.Equal("76", actual.Chapters);
Assert.False(actual.IsSpecial);
}
/// <summary>
/// Tests that when there is a volume in filename, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_JustVolumeInFilename()
{
var actual = _parser.Parse("C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/Vol 1.cbz",
"C:/Books/Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", actual.Series);
Assert.Equal("1", actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.False(actual.IsSpecial);
}
/// <summary>
/// Tests that when there is a chapter only in filename, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_JustChapterInFilename()
{
var actual = _parser.Parse("C:/Books/Beelzebub/Beelzebub_01_[Noodles].zip",
"C:/Books/Beelzebub/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Beelzebub", actual.Series);
Assert.Equal(Parser.LooseLeafVolume, actual.Volumes);
Assert.Equal("1", actual.Chapters);
Assert.False(actual.IsSpecial);
}
/// <summary>
/// Tests that when there is a SP Marker in filename, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_SpecialMarkerInFilename()
{
var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr",
"C:/Books/Summer Time Rendering/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Summer Time Rendering", actual.Series);
Assert.Equal(Parser.SpecialVolume, actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
}
/// <summary>
/// Tests that when the filename parses as a speical, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_SpecialInFilename()
{
var actual = _parser.Parse("C:/Books/Summer Time Rendering/Specials/Volume Omake.cbr",
"C:/Books/Summer Time Rendering/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Summer Time Rendering", actual.Series);
Assert.Equal("Volume Omake", actual.Title);
Assert.Equal(Parser.SpecialVolume, actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
}
/// <summary>
/// Tests that when there is an edition in filename, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaLibrary_EditionInFilename()
{
var actual = _parser.Parse("C:/Books/Air Gear/Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz",
"C:/Books/Air Gear/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Air Gear", actual.Series);
Assert.Equal("1", actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.False(actual.IsSpecial);
Assert.Equal("Omnibus", actual.Edition);
}
#endregion
#region Parse_Books
/// <summary>
/// Tests that when there is a volume in filename, it appropriately parses
/// </summary>
[Fact]
public void Parse_MangaBooks_JustVolumeInFilename()
{
var actual = _parser.Parse("C:/Books/Epubs/Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub",
"C:/Books/Epubs/",
RootDirectory, LibraryType.Manga, null);
Assert.NotNull(actual);
Assert.Equal("Harrison, Kim - The Good, The Bad, and the Undead - Hollows", actual.Series);
Assert.Equal("2.5", actual.Volumes);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
}
#endregion
#region IsApplicable
/// <summary>
/// Tests that this Parser can only be used on images and Image library type
/// </summary>
[Fact]
public void IsApplicable_Fails_WhenNonMatchingLibraryType()
{
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Image));
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.ComicVine));
}
/// <summary>
/// Tests that this Parser can only be used on images and Image library type
/// </summary>
[Fact]
public void IsApplicable_Success_WhenMatchingLibraryType()
{
Assert.True(_parser.IsApplicable("something.png", LibraryType.Manga));
Assert.True(_parser.IsApplicable("something.png", LibraryType.Comic));
Assert.True(_parser.IsApplicable("something.pdf", LibraryType.Book));
Assert.True(_parser.IsApplicable("something.epub", LibraryType.LightNovel));
}
#endregion
}

View file

@ -0,0 +1,74 @@
using System.IO.Abstractions.TestingHelpers;
using API.Data.Metadata;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Parsers;
public class BookParserTests
{
private readonly BookParser _parser;
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
private const string RootDirectory = "C:/Books/";
public BookParserTests()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Books/");
fileSystem.AddFile("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", new MockFileData(""));
fileSystem.AddFile("C:/Books/Adam Freeman - Pro ASP.NET Core 6.epub", new MockFileData(""));
fileSystem.AddFile("C:/Books/My Fav Book SP01.epub", new MockFileData(""));
var ds = new DirectoryService(_dsLogger, fileSystem);
_parser = new BookParser(ds, Substitute.For<IBookService>(), new BasicParser(ds, new ImageParser(ds)));
}
#region Parse
// TODO: I'm not sure how to actually test this as it relies on an epub parser to actually do anything
/// <summary>
/// Tests that if there is a Series Folder then Chapter folder, the code appropriately identifies the Series name and Chapter
/// </summary>
// [Fact]
// public void Parse_SeriesWithDirectoryName()
// {
// var actual = _parser.Parse("C:/Books/Harry Potter/Harry Potter - Vol 1.epub", "C:/Books/Birds of Prey/",
// RootDirectory, LibraryType.Book, new ComicInfo()
// {
// Series = "Harry Potter",
// Volume = "1"
// });
//
// Assert.NotNull(actual);
// Assert.Equal("Harry Potter", actual.Series);
// Assert.Equal("1", actual.Volumes);
// }
#endregion
#region IsApplicable
/// <summary>
/// Tests that this Parser can only be used on images and Image library type
/// </summary>
[Fact]
public void IsApplicable_Fails_WhenNonMatchingLibraryType()
{
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Manga));
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Book));
}
/// <summary>
/// Tests that this Parser can only be used on images and Image library type
/// </summary>
[Fact]
public void IsApplicable_Success_WhenMatchingLibraryType()
{
Assert.True(_parser.IsApplicable("something.epub", LibraryType.Image));
}
#endregion
}

View file

@ -0,0 +1,115 @@
using System.IO.Abstractions.TestingHelpers;
using API.Data.Metadata;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Parsers;
public class ComicVineParserTests
{
private readonly ComicVineParser _parser;
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
private const string RootDirectory = "C:/Comics/";
public ComicVineParserTests()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Comics/");
fileSystem.AddDirectory("C:/Comics/Birds of Prey (2002)");
fileSystem.AddFile("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", new MockFileData(""));
fileSystem.AddFile("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", new MockFileData(""));
fileSystem.AddFile("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", new MockFileData(""));
var ds = new DirectoryService(_dsLogger, fileSystem);
_parser = new ComicVineParser(ds);
}
#region Parse
/// <summary>
/// Tests that when Series and Volume are filled out, Kavita uses that for the Series Name
/// </summary>
[Fact]
public void Parse_SeriesWithComicInfo()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, new ComicInfo()
{
Series = "Birds of Prey",
Volume = "2002"
});
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (2002)", actual.Series);
Assert.Equal("2002", actual.Volumes);
}
/// <summary>
/// Tests that no ComicInfo, take the Directory Name if it matches "Series (2002)" or "Series (2)"
/// </summary>
[Fact]
public void Parse_SeriesWithDirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey (2002)/Birds of Prey 001 (2002).cbz", "C:/Comics/Birds of Prey (2002)/",
RootDirectory, LibraryType.ComicVine, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (2002)", actual.Series);
Assert.Equal("2002", actual.Volumes);
Assert.Equal("1", actual.Chapters);
}
/// <summary>
/// Tests that no ComicInfo, take a directory name up to root if it matches "Series (2002)" or "Series (2)"
/// </summary>
[Fact]
public void Parse_SeriesWithADirectoryNameAsSeriesYear()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Birds of Prey (1999)/Birds of Prey 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey (1999)", actual.Series);
Assert.Equal("1999", actual.Volumes);
Assert.Equal("1", actual.Chapters);
}
/// <summary>
/// Tests that no ComicInfo and nothing matches Series (Volume), then just take the directory name as the Series
/// </summary>
[Fact]
public void Parse_FallbackToDirectoryNameOnly()
{
var actual = _parser.Parse("C:/Comics/DC Comics/Blood Syndicate/Blood Syndicate 001 (1999).cbz", "C:/Comics/DC Comics/",
RootDirectory, LibraryType.ComicVine, null);
Assert.NotNull(actual);
Assert.Equal("Blood Syndicate", actual.Series);
Assert.Equal(Parser.LooseLeafVolume, actual.Volumes);
Assert.Equal("1", actual.Chapters);
}
#endregion
#region IsApplicable
/// <summary>
/// Tests that this Parser can only be used on ComicVine type
/// </summary>
[Fact]
public void IsApplicable_Fails_WhenNonMatchingLibraryType()
{
Assert.False(_parser.IsApplicable("", LibraryType.Comic));
}
/// <summary>
/// Tests that this Parser can only be used on ComicVine type
/// </summary>
[Fact]
public void IsApplicable_Success_WhenMatchingLibraryType()
{
Assert.True(_parser.IsApplicable("", LibraryType.ComicVine));
}
#endregion
}

View file

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
@ -9,9 +7,8 @@ using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
using API.Services.Tasks.Scanner.Parser;
namespace API.Tests.Parser;
namespace API.Tests.Parsers;
public class DefaultParserTests
{
@ -22,10 +19,12 @@ public class DefaultParserTests
{
_testOutputHelper = testOutputHelper;
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem());
_defaultParser = new DefaultParser(directoryService);
_defaultParser = new BasicParser(directoryService, new ImageParser(directoryService));
}
#region ParseFromFallbackFolders
[Theory]
[InlineData("C:/", "C:/Love Hina/Love Hina - Special.cbz", "Love Hina")]
@ -34,7 +33,7 @@ public class DefaultParserTests
[InlineData("C:/", "C:/Something Random/Mujaki no Rakuen SP01.cbz", "Something Random")]
public void ParseFromFallbackFolders_FallbackShouldParseSeries(string rootDir, string inputPath, string expectedSeries)
{
var actual = _defaultParser.Parse(inputPath, rootDir);
var actual = _defaultParser.Parse(inputPath, rootDir, rootDir, LibraryType.Manga, null);
if (actual == null)
{
Assert.NotNull(actual);
@ -52,7 +51,7 @@ public class DefaultParserTests
public void ParseFromFallbackFolders_ShouldParseSeriesVolumeAndChapter(string inputFile, string[] expectedParseInfo)
{
const string rootDirectory = "/manga/";
var actual = new ParserInfo {Series = "", Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume};
var actual = new ParserInfo {Series = "", Chapters = Parser.DefaultChapter, Volumes = Parser.LooseLeafVolume};
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo[0], actual.Series);
Assert.Equal(expectedParseInfo[1], actual.Volumes);
@ -74,8 +73,8 @@ public class DefaultParserTests
fs.AddDirectory(rootDirectory);
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new DefaultParser(ds);
var actual = parser.Parse(inputFile, rootDirectory);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -90,8 +89,8 @@ public class DefaultParserTests
fs.AddDirectory(rootDirectory);
fs.AddFile(inputFile, new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fs);
var parser = new DefaultParser(ds);
var actual = parser.Parse(inputFile, rootDirectory);
var parser = new BasicParser(ds, new ImageParser(ds));
var actual = parser.Parse(inputFile, rootDirectory, rootDirectory, LibraryType.Manga, null);
_defaultParser.ParseFromFallbackFolders(inputFile, rootDirectory, LibraryType.Manga, ref actual);
Assert.Equal(expectedParseInfo, actual.Series);
}
@ -101,13 +100,6 @@ public class DefaultParserTests
#region Parse
[Fact]
public void Parse_MangaLibrary_JustCover_ShouldReturnNull()
{
const string rootPath = @"E:/Manga/";
var actual = _defaultParser.Parse(@"E:/Manga/Accel World/cover.png", rootPath);
Assert.Null(actual);
}
[Fact]
public void Parse_ParseInfo_Manga()
@ -134,11 +126,12 @@ public class DefaultParserTests
filepath = @"E:\Manga\Beelzebub\Beelzebub_01_[Noodles].zip";
expected.Add(filepath, new ParserInfo
{
Series = "Beelzebub", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume,
Series = "Beelzebub", Volumes = Parser.LooseLeafVolume,
Chapters = "1", Filename = "Beelzebub_01_[Noodles].zip", Format = MangaFormat.Archive,
FullFilePath = filepath
});
// Note: Lots of duplicates here. I think I can move them to the ParserTests itself
filepath = @"E:\Manga\Ichinensei ni Nacchattara\Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip";
expected.Add(filepath, new ParserInfo
{
@ -258,7 +251,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Manga, null);
if (expectedInfo == null)
{
Assert.Null(actual);
@ -283,7 +276,7 @@ public class DefaultParserTests
}
}
[Fact]
//[Fact]
public void Parse_ParseInfo_Manga_ImageOnly()
{
// Images don't have root path as E:\Manga, but rather as the path of the folder
@ -296,7 +289,7 @@ public class DefaultParserTests
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Monster #8");
var actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -322,7 +315,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\");
actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga",LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -348,7 +341,7 @@ public class DefaultParserTests
FullFilePath = filepath, IsSpecial = false
};
actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\");
actual2 = _defaultParser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Manga, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
@ -379,7 +372,7 @@ public class DefaultParserTests
filesystem.AddFile(@"E:/Manga/Foo 50/Specials/Foo 50 SP01.cbz", new MockFileData(""));
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var parser = new DefaultParser(ds);
var parser = new BasicParser(ds, new ImageParser(ds));
var filepath = @"E:/Manga/Foo 50/Foo 50 v1.cbz";
// There is a bad parse for series like "Foo 50", so we have parsed chapter as 50
@ -390,7 +383,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
var actual = parser.Parse(filepath, rootPath);
var actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
@ -419,7 +412,7 @@ public class DefaultParserTests
FullFilePath = filepath
};
actual = parser.Parse(filepath, rootPath);
actual = parser.Parse(filepath, rootPath, rootPath, LibraryType.Manga, null);
Assert.NotNull(actual);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expected.Format, actual.Format);
@ -444,7 +437,7 @@ public class DefaultParserTests
[Fact]
public void Parse_ParseInfo_Comic()
{
const string rootPath = @"E:/Comics/";
const string rootPath = "E:/Comics/";
var expected = new Dictionary<string, ParserInfo>();
var filepath = @"E:/Comics/Teen Titans/Teen Titans v1 Annual 01 (1967) SP01.cbr";
expected.Add(filepath, new ParserInfo
@ -482,7 +475,7 @@ public class DefaultParserTests
foreach (var file in expected.Keys)
{
var expectedInfo = expected[file];
var actual = _defaultParser.Parse(file, rootPath, LibraryType.Comic);
var actual = _defaultParser.Parse(file, rootPath, rootPath, LibraryType.Comic, null);
if (expectedInfo == null)
{
Assert.Null(actual);

View file

@ -0,0 +1,97 @@
using System.IO.Abstractions.TestingHelpers;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Parsers;
public class ImageParserTests
{
private readonly ImageParser _parser;
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
private const string RootDirectory = "C:/Comics/";
public ImageParserTests()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Comics/");
fileSystem.AddDirectory("C:/Comics/Birds of Prey (2002)");
fileSystem.AddFile("C:/Comics/Birds of Prey/Chapter 01/01.jpg", new MockFileData(""));
fileSystem.AddFile("C:/Comics/DC Comics/Birds of Prey/Chapter 01/01.jpg", new MockFileData(""));
var ds = new DirectoryService(_dsLogger, fileSystem);
_parser = new ImageParser(ds);
}
#region Parse
/// <summary>
/// Tests that if there is a Series Folder then Chapter folder, the code appropriately identifies the Series name and Chapter
/// </summary>
[Fact]
public void Parse_SeriesWithDirectoryName()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01/01.jpg", "C:/Comics/Birds of Prey/",
RootDirectory, LibraryType.Image, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
Assert.Equal("1", actual.Chapters);
}
/// <summary>
/// Tests that if there is a Series Folder only, the code appropriately identifies the Series name from folder
/// </summary>
[Fact]
public void Parse_SeriesWithNoNestedChapter()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/Chapter 01 page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
}
/// <summary>
/// Tests that if there is a Series Folder only, the code appropriately identifies the Series name from folder and everything else as a
/// </summary>
[Fact]
public void Parse_SeriesWithLooseImages()
{
var actual = _parser.Parse("C:/Comics/Birds of Prey/page 01.jpg", "C:/Comics/",
RootDirectory, LibraryType.Image, null);
Assert.NotNull(actual);
Assert.Equal("Birds of Prey", actual.Series);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
}
#endregion
#region IsApplicable
/// <summary>
/// Tests that this Parser can only be used on images and Image library type
/// </summary>
[Fact]
public void IsApplicable_Fails_WhenNonMatchingLibraryType()
{
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Manga));
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Image));
Assert.False(_parser.IsApplicable("something.epub", LibraryType.Image));
}
/// <summary>
/// Tests that this Parser can only be used on images and Image library type
/// </summary>
[Fact]
public void IsApplicable_Success_WhenMatchingLibraryType()
{
Assert.True(_parser.IsApplicable("something.png", LibraryType.Image));
}
#endregion
}

View file

@ -0,0 +1,71 @@
using System.IO.Abstractions.TestingHelpers;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Parsers;
public class PdfParserTests
{
private readonly PdfParser _parser;
private readonly ILogger<DirectoryService> _dsLogger = Substitute.For<ILogger<DirectoryService>>();
private const string RootDirectory = "C:/Books/";
public PdfParserTests()
{
var fileSystem = new MockFileSystem();
fileSystem.AddDirectory("C:/Books/");
fileSystem.AddDirectory("C:/Books/Birds of Prey (2002)");
fileSystem.AddFile("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf", new MockFileData(""));
fileSystem.AddFile("C:/Comics/DC Comics/Birds of Prey/Chapter 01/01.jpg", new MockFileData(""));
var ds = new DirectoryService(_dsLogger, fileSystem);
_parser = new PdfParser(ds);
}
#region Parse
/// <summary>
/// Tests that if there is a Series Folder then Chapter folder, the code appropriately identifies the Series name and Chapter
/// </summary>
[Fact]
public void Parse_Book_SeriesWithDirectoryName()
{
var actual = _parser.Parse("C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/A Dictionary of Japanese Food - Ingredients and Culture.pdf",
"C:/Books/A Dictionary of Japanese Food - Ingredients and Culture/",
RootDirectory, LibraryType.Book, null);
Assert.NotNull(actual);
Assert.Equal("A Dictionary of Japanese Food - Ingredients and Culture", actual.Series);
Assert.Equal(Parser.DefaultChapter, actual.Chapters);
Assert.True(actual.IsSpecial);
}
#endregion
#region IsApplicable
/// <summary>
/// Tests that this Parser can only be used on pdfs
/// </summary>
[Fact]
public void IsApplicable_Fails_WhenNonMatchingLibraryType()
{
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Manga));
Assert.False(_parser.IsApplicable("something.cbz", LibraryType.Image));
Assert.False(_parser.IsApplicable("something.epub", LibraryType.Image));
Assert.False(_parser.IsApplicable("something.png", LibraryType.Book));
}
/// <summary>
/// Tests that this Parser can only be used on pdfs
/// </summary>
[Fact]
public void IsApplicable_Success_WhenMatchingLibraryType()
{
Assert.True(_parser.IsApplicable("something.pdf", LibraryType.Book));
Assert.True(_parser.IsApplicable("something.pdf", LibraryType.Manga));
}
#endregion
}

View file

@ -1,8 +1,8 @@
using Xunit;
namespace API.Tests.Parser;
namespace API.Tests.Parsing;
public class BookParserTests
public class BookParsingTests
{
[Theory]
[InlineData("Gifting The Wonderful World With Blessings! - 3 Side Stories [yuNS][Unknown]", "Gifting The Wonderful World With Blessings!")]

View file

@ -6,20 +6,18 @@ using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Parser;
namespace API.Tests.Parsing;
public class ComicParserTests
public class ComicParsingTests
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly DefaultParser _defaultParser;
private static readonly string DefaultVolume = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume;
private readonly IDefaultParser _basicParser;
public ComicParserTests(ITestOutputHelper testOutputHelper)
public ComicParsingTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
_defaultParser =
new DefaultParser(new DirectoryService(Substitute.For<ILogger<DirectoryService>>(),
new MockFileSystem()));
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem());
_basicParser = new BasicParser(directoryService, new ImageParser(directoryService));
}
[Theory]

View file

@ -0,0 +1,107 @@
using System.IO.Abstractions.TestingHelpers;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Parsing;
public class ImageParsingTests
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly ImageParser _parser;
public ImageParsingTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
var directoryService = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem());
_parser = new ImageParser(directoryService);
}
//[Fact]
public void Parse_ParseInfo_Manga_ImageOnly()
{
// Images don't have root path as E:\Manga, but rather as the path of the folder
// Note: Fallback to folder will parse Monster #8 and get Monster
var filepath = @"E:\Manga\Monster #8\Ch. 001-016 [MangaPlus] [Digital] [amit34521]\Monster #8 Ch. 001 [MangaPlus] [Digital] [amit34521]\13.jpg";
var expectedInfo2 = new ParserInfo
{
Series = "Monster #8", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "",
Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
var actual2 = _parser.Parse(filepath, @"E:\Manga\Monster #8", "E:/Manga", LibraryType.Image, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
_testOutputHelper.WriteLine("Format ✓");
Assert.Equal(expectedInfo2.Series, actual2.Series);
_testOutputHelper.WriteLine("Series ✓");
Assert.Equal(expectedInfo2.Chapters, actual2.Chapters);
_testOutputHelper.WriteLine("Chapters ✓");
Assert.Equal(expectedInfo2.Volumes, actual2.Volumes);
_testOutputHelper.WriteLine("Volumes ✓");
Assert.Equal(expectedInfo2.Edition, actual2.Edition);
_testOutputHelper.WriteLine("Edition ✓");
Assert.Equal(expectedInfo2.Filename, actual2.Filename);
_testOutputHelper.WriteLine("Filename ✓");
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
_testOutputHelper.WriteLine("FullFilePath ✓");
filepath = @"E:\Manga\Extra layer for no reason\Just Images the second\Vol19\ch. 186\Vol. 19 p106.gif";
expectedInfo2 = new ParserInfo
{
Series = "Just Images the second", Volumes = "19", Edition = "",
Chapters = "186", Filename = "Vol. 19 p106.gif", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
_testOutputHelper.WriteLine("Format ✓");
Assert.Equal(expectedInfo2.Series, actual2.Series);
_testOutputHelper.WriteLine("Series ✓");
Assert.Equal(expectedInfo2.Chapters, actual2.Chapters);
_testOutputHelper.WriteLine("Chapters ✓");
Assert.Equal(expectedInfo2.Volumes, actual2.Volumes);
_testOutputHelper.WriteLine("Volumes ✓");
Assert.Equal(expectedInfo2.Edition, actual2.Edition);
_testOutputHelper.WriteLine("Edition ✓");
Assert.Equal(expectedInfo2.Filename, actual2.Filename);
_testOutputHelper.WriteLine("Filename ✓");
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
_testOutputHelper.WriteLine("FullFilePath ✓");
filepath = @"E:\Manga\Extra layer for no reason\Just Images the second\Blank Folder\Vol19\ch. 186\Vol. 19 p106.gif";
expectedInfo2 = new ParserInfo
{
Series = "Just Images the second", Volumes = "19", Edition = "",
Chapters = "186", Filename = "Vol. 19 p106.gif", Format = MangaFormat.Image,
FullFilePath = filepath, IsSpecial = false
};
actual2 = _parser.Parse(filepath, @"E:\Manga\Extra layer for no reason\", "E:/Manga", LibraryType.Image, null);
Assert.NotNull(actual2);
_testOutputHelper.WriteLine($"Validating {filepath}");
Assert.Equal(expectedInfo2.Format, actual2.Format);
_testOutputHelper.WriteLine("Format ✓");
Assert.Equal(expectedInfo2.Series, actual2.Series);
_testOutputHelper.WriteLine("Series ✓");
Assert.Equal(expectedInfo2.Chapters, actual2.Chapters);
_testOutputHelper.WriteLine("Chapters ✓");
Assert.Equal(expectedInfo2.Volumes, actual2.Volumes);
_testOutputHelper.WriteLine("Volumes ✓");
Assert.Equal(expectedInfo2.Edition, actual2.Edition);
_testOutputHelper.WriteLine("Edition ✓");
Assert.Equal(expectedInfo2.Filename, actual2.Filename);
_testOutputHelper.WriteLine("Filename ✓");
Assert.Equal(expectedInfo2.FullFilePath, actual2.FullFilePath);
_testOutputHelper.WriteLine("FullFilePath ✓");
}
}

View file

@ -2,13 +2,13 @@ using API.Entities.Enums;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Parser;
namespace API.Tests.Parsing;
public class MangaParserTests
public class MangaParsingTests
{
private readonly ITestOutputHelper _testOutputHelper;
public MangaParserTests(ITestOutputHelper testOutputHelper)
public MangaParsingTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
}

View file

@ -2,7 +2,7 @@
using API.Services.Tasks.Scanner.Parser;
using Xunit;
namespace API.Tests.Parser;
namespace API.Tests.Parsing;
public class ParserInfoTests
{

View file

@ -3,9 +3,9 @@ using System.Linq;
using Xunit;
using static API.Services.Tasks.Scanner.Parser.Parser;
namespace API.Tests.Parser;
namespace API.Tests.Parsing;
public class ParserTests
public class ParsingTests
{
[Fact]
public void ShouldWork()

View file

@ -52,12 +52,12 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
throw new System.NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, LibraryType type)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
throw new System.NotImplementedException();
}
public ParserInfo ParseFile(string path, string rootPath, LibraryType type)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
throw new System.NotImplementedException();
}
@ -156,7 +156,9 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(),
Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
await ResetDB();
var s = new SeriesBuilder("Test").Build();
@ -231,7 +233,8 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cleanupService = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
cleanupService.CleanupChapters(new []{1, 3});
Assert.Empty(ds.GetFiles(CacheDirectory, searchOption:SearchOption.AllDirectories));
@ -252,7 +255,8 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cs = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
var c = new ChapterBuilder("1")
.WithFile(new MangaFileBuilder($"{DataDirectory}1.epub", MangaFormat.Epub).Build())
@ -292,7 +296,8 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cs = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
// Flatten to prepare for how GetFullPath expects
ds.Flatten($"{CacheDirectory}1/");
@ -335,7 +340,8 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cs = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
// Flatten to prepare for how GetFullPath expects
ds.Flatten($"{CacheDirectory}1/");
@ -375,7 +381,8 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cs = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
// Flatten to prepare for how GetFullPath expects
ds.Flatten($"{CacheDirectory}1/");
@ -419,7 +426,8 @@ public class CacheServiceTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var cs = new CacheService(_logger, _unitOfWork, ds,
new ReadingItemService(Substitute.For<IArchiveService>(),
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds), Substitute.For<IBookmarkService>());
Substitute.For<IBookService>(), Substitute.For<IImageService>(), ds, Substitute.For<ILogger<ReadingItemService>>()),
Substitute.For<IBookmarkService>());
// Flatten to prepare for how GetFullPath expects
ds.Flatten($"{CacheDirectory}1/");

View file

@ -54,14 +54,14 @@ internal class MockReadingItemService : IReadingItemService
throw new NotImplementedException();
}
public ParserInfo Parse(string path, string rootPath, LibraryType type)
public ParserInfo Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
return _defaultParser.Parse(path, rootPath, type);
return _defaultParser.Parse(path, rootPath, libraryRoot, type);
}
public ParserInfo ParseFile(string path, string rootPath, LibraryType type)
public ParserInfo ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
return _defaultParser.Parse(path, rootPath, type);
return _defaultParser.Parse(path, rootPath, libraryRoot, type);
}
}
@ -231,7 +231,7 @@ public class ParseScannedFilesTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
var parsedSeries = new Dictionary<ParsedSeries, IList<ParserInfo>>();
@ -289,14 +289,14 @@ public class ParseScannedFilesTests
var fileSystem = CreateTestFilesystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
var directoriesSeen = new HashSet<string>();
var library =
await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1,
LibraryIncludes.Folders | LibraryIncludes.FileTypes);
await psf.ProcessFiles("C:/Data/", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),
(files, directoryPath) =>
(files, directoryPath, libraryFolder) =>
{
directoriesSeen.Add(directoryPath);
return Task.CompletedTask;
@ -311,11 +311,11 @@ public class ParseScannedFilesTests
var fileSystem = CreateTestFilesystem();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
var directoriesSeen = new HashSet<string>();
await psf.ProcessFiles("C:/Data/", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),
(files, directoryPath) =>
(files, directoryPath, libraryFolder) =>
{
directoriesSeen.Add(directoryPath);
return Task.CompletedTask;
@ -342,10 +342,11 @@ public class ParseScannedFilesTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
var callCount = 0;
await psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),(files, folderPath) =>
await psf.ProcessFiles("C:/Data", true, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),
(files, folderPath, libraryFolder) =>
{
callCount++;
@ -375,10 +376,11 @@ public class ParseScannedFilesTests
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), fileSystem);
var psf = new ParseScannedFiles(Substitute.For<ILogger<ParseScannedFiles>>(), ds,
new MockReadingItemService(new DefaultParser(ds)), Substitute.For<IEventHub>());
new MockReadingItemService(new BasicParser(ds, new ImageParser(ds))), Substitute.For<IEventHub>());
var callCount = 0;
await psf.ProcessFiles("C:/Data", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),(files, folderPath) =>
await psf.ProcessFiles("C:/Data", false, await _unitOfWork.SeriesRepository.GetFolderPathMap(1),
(files, folderPath, libraryFolder) =>
{
callCount++;
return Task.CompletedTask;

File diff suppressed because it is too large Load diff

View file

@ -48,6 +48,7 @@ public enum FilterField
/// <summary>
/// Average rating from Kavita+ - Not usable for non-licensed users
/// </summary>
AverageRating = 28
AverageRating = 28,
Imprint = 29
}

View file

@ -18,6 +18,7 @@ public class ChapterMetadataDto
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Imprints { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();

View file

@ -22,4 +22,5 @@ public class RelatedSeriesDto
public IEnumerable<SeriesDto> Doujinshis { get; set; } = default!;
public IEnumerable<SeriesDto> Parent { get; set; } = default!;
public IEnumerable<SeriesDto> Editions { get; set; } = default!;
public IEnumerable<SeriesDto> Annuals { get; set; } = default!;
}

View file

@ -17,4 +17,5 @@ public class UpdateRelatedSeriesDto
public IList<int> AlternativeVersions { get; set; } = default!;
public IList<int> Doujinshis { get; set; } = default!;
public IList<int> Editions { get; set; } = default!;
public IList<int> Annuals { get; set; } = default!;
}

View file

@ -30,6 +30,7 @@ public class SeriesMetadataDto
public ICollection<PersonDto> Characters { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Pencillers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Inkers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Imprints { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Colorists { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Letterers { get; set; } = new List<PersonDto>();
public ICollection<PersonDto> Editors { get; set; } = new List<PersonDto>();
@ -80,6 +81,7 @@ public class SeriesMetadataDto
public bool ColoristLocked { get; set; }
public bool EditorLocked { get; set; }
public bool InkerLocked { get; set; }
public bool ImprintLocked { get; set; }
public bool LettererLocked { get; set; }
public bool PencillerLocked { get; set; }
public bool PublisherLocked { get; set; }

View file

@ -127,8 +127,10 @@ public class ComicInfo
public string CoverArtist { get; set; } = string.Empty;
public string Editor { get; set; } = string.Empty;
public string Publisher { get; set; } = string.Empty;
public string Imprint { get; set; } = string.Empty;
public string Characters { get; set; } = string.Empty;
public static AgeRating ConvertAgeRatingToEnum(string value)
{
if (string.IsNullOrEmpty(value)) return Entities.Enums.AgeRating.Unknown;
@ -151,6 +153,7 @@ public class ComicInfo
info.Letterer = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Letterer);
info.Penciller = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Penciller);
info.Publisher = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Publisher);
info.Imprint = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Imprint);
info.Characters = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Characters);
info.Translator = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Translator);
info.CoverArtist = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.CoverArtist);

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class SeriesImprints : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ImprintLocked",
table: "SeriesMetadata",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ImprintLocked",
table: "SeriesMetadata");
}
}
}

View file

@ -1253,6 +1253,9 @@ namespace API.Data.Migrations
b.Property<bool>("GenresLocked")
.HasColumnType("INTEGER");
b.Property<bool>("ImprintLocked")
.HasColumnType("INTEGER");
b.Property<bool>("InkerLocked")
.HasColumnType("INTEGER");

View file

@ -1184,6 +1184,7 @@ public class SeriesRepository : ISeriesRepository
FilterField.Letterer => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Colorist => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Inker => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Imprint => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Penciller => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Writers => query.HasPeople(true, statement.Comparison, (IList<int>) value),
FilterField.Genres => query.HasGenre(true, statement.Comparison, (IList<int>) value),
@ -1818,19 +1819,7 @@ public class SeriesRepository : ISeriesRepository
AlternativeSettings = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeSetting, userRating),
AlternativeVersions = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.AlternativeVersion, userRating),
Doujinshis = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Doujinshi, userRating),
// Parent = await _context.Series
// .SelectMany(s =>
// s.TargetSeries.Where(r => r.TargetSeriesId == seriesId
// && usersSeriesIds.Contains(r.TargetSeriesId)
// && r.RelationKind != RelationKind.Prequel
// && r.RelationKind != RelationKind.Sequel
// && r.RelationKind != RelationKind.Edition)
// .Select(sr => sr.Series))
// .RestrictAgainstAgeRestriction(userRating)
// .AsSplitQuery()
// .AsNoTracking()
// .ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
// .ToListAsync(),
Annuals = await GetRelatedSeriesQuery(seriesId, usersSeriesIds, RelationKind.Annual, userRating),
Parent = await _context.SeriesRelation
.Where(r => r.TargetSeriesId == seriesId
&& usersSeriesIds.Contains(r.TargetSeriesId)

View file

@ -29,4 +29,10 @@ public enum LibraryType
/// </summary>
[Description("Light Novel")]
LightNovel = 4,
/// <summary>
/// Uses Comic regex for filename parsing, uses ComicVine type of Parsing. Will replace Comic type in future
/// </summary>
[Description("Comic (ComicVine)")]
ComicVine = 5,
}

View file

@ -24,7 +24,11 @@ public enum PersonRole
/// <summary>
/// The Translator
/// </summary>
Translator = 12
Translator = 12,
/// <summary>
/// The publisher before another Publisher bought
/// </summary>
Imprint = 13
}

View file

@ -71,6 +71,11 @@ public enum RelationKind
/// Same story, could be translation, colorization... Different edition of the series
/// </summary>
[Description("Edition")]
Edition = 13
Edition = 13,
/// <summary>
/// The target series is an annual of the Series
/// </summary>
[Description("Annual")]
Annual = 14
}

View file

@ -68,6 +68,7 @@ public class SeriesMetadata : IHasConcurrencyToken
public bool ColoristLocked { get; set; }
public bool EditorLocked { get; set; }
public bool InkerLocked { get; set; }
public bool ImprintLocked { get; set; }
public bool LettererLocked { get; set; }
public bool PencillerLocked { get; set; }
public bool PublisherLocked { get; set; }

View file

@ -154,6 +154,9 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Inkers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Inker).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Imprints,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Imprint).OrderBy(p => p.NormalizedName)))
.ForMember(dest => dest.Letterers,
opt =>
opt.MapFrom(src => src.People.Where(p => p.Role == PersonRole.Letterer).OrderBy(p => p.NormalizedName)))

View file

@ -58,6 +58,9 @@ public static class FilterFieldValueConverter
FilterField.Inker => value.Split(',')
.Select(int.Parse)
.ToList(),
FilterField.Imprint => value.Split(',')
.Select(int.Parse)
.ToList(),
FilterField.Penciller => value.Split(',')
.Select(int.Parse)
.ToList(),

View file

@ -2,6 +2,7 @@
using API.Data.Metadata;
using API.Entities.Enums;
using API.Services.Tasks.Scanner.Parser;
using Microsoft.Extensions.Logging;
namespace API.Services;
#nullable enable
@ -12,7 +13,7 @@ public interface IReadingItemService
int GetNumberOfPages(string filePath, MangaFormat format);
string GetCoverImage(string filePath, string fileName, MangaFormat format, EncodeFormat encodeFormat, CoverImageSize size = CoverImageSize.Default);
void Extract(string fileFilePath, string targetDirectory, MangaFormat format, int imageCount = 1);
ParserInfo? ParseFile(string path, string rootPath, LibraryType type);
ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type);
}
public class ReadingItemService : IReadingItemService
@ -21,16 +22,27 @@ public class ReadingItemService : IReadingItemService
private readonly IBookService _bookService;
private readonly IImageService _imageService;
private readonly IDirectoryService _directoryService;
private readonly IDefaultParser _defaultParser;
private readonly ILogger<ReadingItemService> _logger;
private readonly BasicParser _basicParser;
private readonly ComicVineParser _comicVineParser;
private readonly ImageParser _imageParser;
private readonly BookParser _bookParser;
private readonly PdfParser _pdfParser;
public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, IDirectoryService directoryService)
public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService,
IDirectoryService directoryService, ILogger<ReadingItemService> logger)
{
_archiveService = archiveService;
_bookService = bookService;
_imageService = imageService;
_directoryService = directoryService;
_logger = logger;
_defaultParser = new DefaultParser(directoryService);
_comicVineParser = new ComicVineParser(directoryService);
_imageParser = new ImageParser(directoryService);
_bookParser = new BookParser(directoryService, bookService, _basicParser);
_pdfParser = new PdfParser(directoryService);
_basicParser = new BasicParser(directoryService, _imageParser);
}
/// <summary>
@ -59,37 +71,15 @@ public class ReadingItemService : IReadingItemService
/// <param name="path">Path of a file</param>
/// <param name="rootPath"></param>
/// <param name="type">Library type to determine parsing to perform</param>
public ParserInfo? ParseFile(string path, string rootPath, LibraryType type)
public ParserInfo? ParseFile(string path, string rootPath, string libraryRoot, LibraryType type)
{
var info = Parse(path, rootPath, type);
var info = Parse(path, rootPath, libraryRoot, type);
if (info == null)
{
_logger.LogError("Unable to parse any meaningful information out of file {FilePath}", path);
return null;
}
// This catches when original library type is Manga/Comic and when parsing with non
if (Parser.IsEpub(path) && Parser.ParseVolume(info.Series) != Parser.LooseLeafVolume) // Shouldn't this be info.Volume != DefaultVolume?
{
var hasVolumeInTitle = !Parser.ParseVolume(info.Title)
.Equals(Parser.LooseLeafVolume);
var hasVolumeInSeries = !Parser.ParseVolume(info.Series)
.Equals(Parser.LooseLeafVolume);
if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series)))
{
// This is likely a light novel for which we can set series from parsed title
info.Series = Parser.ParseSeries(info.Title);
info.Volumes = Parser.ParseVolume(info.Title);
}
else
{
var info2 = _defaultParser.Parse(path, rootPath, LibraryType.Book);
info.Merge(info2);
}
}
return info;
}
@ -176,55 +166,29 @@ public class ReadingItemService : IReadingItemService
/// <param name="rootPath"></param>
/// <param name="type"></param>
/// <returns></returns>
private ParserInfo? Parse(string path, string rootPath, LibraryType type)
private ParserInfo? Parse(string path, string rootPath, string libraryRoot, LibraryType type)
{
var info = Parser.IsEpub(path) ? _bookService.ParseInfo(path) : _defaultParser.Parse(path, rootPath, type);
if (info == null) return null;
info.ComicInfo = GetComicInfo(path);
if (info.ComicInfo == null) return info;
if (!string.IsNullOrEmpty(info.ComicInfo.Volume))
if (_comicVineParser.IsApplicable(path, type))
{
info.Volumes = info.ComicInfo.Volume;
return _comicVineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (!string.IsNullOrEmpty(info.ComicInfo.Series))
if (_imageParser.IsApplicable(path, type))
{
info.Series = info.ComicInfo.Series.Trim();
return _imageParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (!string.IsNullOrEmpty(info.ComicInfo.Number))
if (_bookParser.IsApplicable(path, type))
{
info.Chapters = info.ComicInfo.Number;
if (info.IsSpecial && Parser.DefaultChapter != info.Chapters)
{
info.IsSpecial = false;
return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (_pdfParser.IsApplicable(path, type))
{
return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
if (_basicParser.IsApplicable(path, type))
{
return _basicParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path));
}
// Patch is SeriesSort from ComicInfo
if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort))
{
info.SeriesSort = info.ComicInfo.TitleSort.Trim();
}
if (!string.IsNullOrEmpty(info.ComicInfo.Format) && Parser.HasComicInfoSpecial(info.ComicInfo.Format))
{
info.IsSpecial = true;
info.Chapters = Parser.DefaultChapter;
info.Volumes = Parser.LooseLeafVolume;
}
if (!string.IsNullOrEmpty(info.ComicInfo.SeriesSort))
{
info.SeriesSort = info.ComicInfo.SeriesSort.Trim();
}
if (!string.IsNullOrEmpty(info.ComicInfo.LocalizedSeries))
{
info.LocalizedSeries = info.ComicInfo.LocalizedSeries.Trim();
}
return info;
return null;
}
}

View file

@ -40,7 +40,7 @@ public interface ISeriesService
Task<string> FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true);
Task<string> FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true);
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle,
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle,
bool withHash);
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
Task<NextExpectedChapterDto> GetEstimatedChapterCreationDate(int seriesId, int userId);
@ -171,7 +171,7 @@ public class SeriesService : ISeriesService
}
if (updateSeriesMetadataDto.CollectionTags.Any())
if (updateSeriesMetadataDto.CollectionTags.Count > 0)
{
var allCollectionTags = (await _unitOfWork.CollectionTagRepository
.GetAllTagsByNamesAsync(updateSeriesMetadataDto.CollectionTags.Select(t => Parser.Normalize(t.Title)))).ToList();
@ -195,7 +195,7 @@ public class SeriesService : ISeriesService
}
if (updateSeriesMetadataDto.SeriesMetadata?.Tags != null && updateSeriesMetadataDto.SeriesMetadata.Tags.Any())
if (updateSeriesMetadataDto.SeriesMetadata?.Tags is {Count: > 0})
{
var allTags = (await _unitOfWork.TagRepository
.GetAllTagsByNameAsync(updateSeriesMetadataDto.SeriesMetadata.Tags.Select(t => Parser.Normalize(t.Title))))
@ -207,7 +207,8 @@ public class SeriesService : ISeriesService
}, () => series.Metadata.TagsLocked = true);
}
if (updateSeriesMetadataDto.SeriesMetadata != null)
{
if (PersonHelper.HasAnyPeople(updateSeriesMetadataDto.SeriesMetadata))
{
void HandleAddPerson(Person person)
@ -218,7 +219,7 @@ public class SeriesService : ISeriesService
series.Metadata.People ??= new List<Person>();
var allWriters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Writer,
updateSeriesMetadataDto.SeriesMetadata!.Writers.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata!.Writers, series, allWriters.AsReadOnly(),
PersonHelper.UpdatePeopleList(PersonRole.Writer, updateSeriesMetadataDto.SeriesMetadata.Writers, series, allWriters.AsReadOnly(),
HandleAddPerson, () => series.Metadata.WriterLocked = true);
var allCharacters = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Character,
@ -256,6 +257,11 @@ public class SeriesService : ISeriesService
PersonHelper.UpdatePeopleList(PersonRole.Publisher, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allPublishers.AsReadOnly(),
HandleAddPerson, () => series.Metadata.PublisherLocked = true);
var allImprints = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Imprint,
updateSeriesMetadataDto.SeriesMetadata!.Imprints.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Imprint, updateSeriesMetadataDto.SeriesMetadata.Publishers, series, allImprints.AsReadOnly(),
HandleAddPerson, () => series.Metadata.ImprintLocked = true);
var allTranslators = await _unitOfWork.PersonRepository.GetAllPeopleByRoleAndNames(PersonRole.Translator,
updateSeriesMetadataDto.SeriesMetadata!.Translators.Select(p => Parser.Normalize(p.Name)));
PersonHelper.UpdatePeopleList(PersonRole.Translator, updateSeriesMetadataDto.SeriesMetadata.Translators, series, allTranslators.AsReadOnly(),
@ -267,8 +273,6 @@ public class SeriesService : ISeriesService
HandleAddPerson, () => series.Metadata.CoverArtistLocked = true);
}
if (updateSeriesMetadataDto.SeriesMetadata != null)
{
series.Metadata.AgeRatingLocked = updateSeriesMetadataDto.SeriesMetadata.AgeRatingLocked;
series.Metadata.PublicationStatusLocked = updateSeriesMetadataDto.SeriesMetadata.PublicationStatusLocked;
series.Metadata.LanguageLocked = updateSeriesMetadataDto.SeriesMetadata.LanguageLocked;
@ -278,6 +282,7 @@ public class SeriesService : ISeriesService
series.Metadata.ColoristLocked = updateSeriesMetadataDto.SeriesMetadata.ColoristLocked;
series.Metadata.EditorLocked = updateSeriesMetadataDto.SeriesMetadata.EditorLocked;
series.Metadata.InkerLocked = updateSeriesMetadataDto.SeriesMetadata.InkerLocked;
series.Metadata.ImprintLocked = updateSeriesMetadataDto.SeriesMetadata.ImprintLocked;
series.Metadata.LettererLocked = updateSeriesMetadataDto.SeriesMetadata.LettererLocked;
series.Metadata.PencillerLocked = updateSeriesMetadataDto.SeriesMetadata.PencillerLocked;
series.Metadata.PublisherLocked = updateSeriesMetadataDto.SeriesMetadata.PublisherLocked;
@ -519,8 +524,10 @@ public class SeriesService : ISeriesService
foreach (var chapter in chapters)
{
if (!string.IsNullOrEmpty(chapter.TitleName)) chapter.Title = chapter.TitleName;
else chapter.Title = await FormatChapterTitle(userId, chapter, libraryType);
// if (!string.IsNullOrEmpty(chapter.TitleName)) chapter.Title = chapter.TitleName;
// else chapter.Title = await FormatChapterTitle(userId, chapter, libraryType);
chapter.Title = await FormatChapterTitle(userId, chapter, libraryType);
if (!chapter.IsSpecial) continue;
specials.Add(chapter);
@ -563,7 +570,6 @@ public class SeriesService : ISeriesService
public static bool RenameVolumeName(VolumeDto volume, LibraryType libraryType, string volumeLabel = "Volume")
{
// TODO: Move this into DB (not sure how because of localization and lookups)
if (libraryType is LibraryType.Book or LibraryType.LightNovel)
{
var firstChapter = volume.Chapters.First();
@ -590,36 +596,47 @@ public class SeriesService : ISeriesService
}
public async Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string? chapterTitle, bool withHash)
public async Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash)
{
if (string.IsNullOrEmpty(chapterTitle)) throw new ArgumentException("Chapter Title cannot be null");
if (string.IsNullOrEmpty(chapterTitle) && (isSpecial || libraryType == LibraryType.Book)) throw new ArgumentException("Chapter Title cannot be null");
if (isSpecial)
{
return Parser.CleanSpecialTitle(chapterTitle);
return Parser.CleanSpecialTitle(chapterTitle!);
}
var hashSpot = withHash ? "#" : string.Empty;
return libraryType switch
var baseChapter = libraryType switch
{
LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle),
LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", chapterTitle),
LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterTitle),
LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterTitle),
LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle!),
LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", chapterRange),
LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange),
LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange),
LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterRange),
LibraryType.Image => await _localizationService.Translate(userId, "chapter-num", chapterRange),
_ => await _localizationService.Translate(userId, "chapter-num", ' ')
};
if (!string.IsNullOrEmpty(chapterTitle) && libraryType != LibraryType.Book && chapterTitle != chapterRange)
{
baseChapter += " - " + chapterTitle;
}
return baseChapter;
}
public async Task<string> FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true)
{
return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Title, withHash);
return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash);
}
public async Task<string> FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true)
{
return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Title, withHash);
return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash);
}
// TODO: Refactor this out and use FormatChapterTitle instead across library
public async Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false)
{
var hashSpot = withHash ? "#" : string.Empty;
@ -628,6 +645,7 @@ public class SeriesService : ISeriesService
LibraryType.Book => await _localizationService.Translate(userId, "book-num", string.Empty),
LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", string.Empty),
LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty),
LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, string.Empty),
LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", string.Empty),
_ => await _localizationService.Translate(userId, "chapter-num", ' ')
}).Trim();
@ -666,6 +684,7 @@ public class SeriesService : ISeriesService
UpdateRelationForKind(dto.Prequels, series.Relations.Where(r => r.RelationKind == RelationKind.Prequel).ToList(), series, RelationKind.Prequel);
UpdateRelationForKind(dto.Sequels, series.Relations.Where(r => r.RelationKind == RelationKind.Sequel).ToList(), series, RelationKind.Sequel);
UpdateRelationForKind(dto.Editions, series.Relations.Where(r => r.RelationKind == RelationKind.Edition).ToList(), series, RelationKind.Edition);
UpdateRelationForKind(dto.Annuals, series.Relations.Where(r => r.RelationKind == RelationKind.Annual).ToList(), series, RelationKind.Annual);
if (!_unitOfWork.HasChanges()) return true;
return await _unitOfWork.CommitAsync();

View file

@ -45,8 +45,6 @@ public class BackupService : IBackupService
_backupFiles = new List<string>()
{
"appsettings.json",
"Hangfire.db", // This is not used atm
"Hangfire-log.db", // This is not used atm
"kavita.db",
"kavita.db-shm", // This wont always be there
"kavita.db-wal" // This wont always be there
@ -109,19 +107,21 @@ public class BackupService : IBackupService
_directoryService.CopyFilesToDirectory(
_backupFiles.Select(file => _directoryService.FileSystem.Path.Join(_directoryService.ConfigDirectory, file)).ToList(), tempDirectory);
await SendProgress(0.2F, "Copying logs");
CopyLogsToBackupDirectory(tempDirectory);
await SendProgress(0.25F, "Copying cover images");
await CopyCoverImagesToBackupDirectory(tempDirectory);
await SendProgress(0.5F, "Copying bookmarks");
await SendProgress(0.35F, "Copying templates images");
CopyTemplatesToBackupDirectory(tempDirectory);
await SendProgress(0.5F, "Copying bookmarks");
await CopyBookmarksToBackupDirectory(tempDirectory);
await SendProgress(0.75F, "Copying themes");
CopyThemesToBackupDirectory(tempDirectory);
await SendProgress(0.85F, "Copying favicons");
CopyFaviconsToBackupDirectory(tempDirectory);
@ -150,6 +150,11 @@ public class BackupService : IBackupService
_directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, _directoryService.FileSystem.Path.Join(tempDirectory, "favicons"));
}
private void CopyTemplatesToBackupDirectory(string tempDirectory)
{
_directoryService.CopyDirectoryToDirectory(_directoryService.TemplateDirectory, _directoryService.FileSystem.Path.Join(tempDirectory, "templates"));
}
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
{
var outputTempDir = Path.Join(tempDirectory, "covers");

View file

@ -78,7 +78,7 @@ public class ParseScannedFiles
/// <param name="folderAction">A callback async Task to be called once all files for each folder path are found</param>
/// <param name="forceCheck">If we should bypass any folder last write time checks on the scan and force I/O</param>
public async Task ProcessFiles(string folderPath, bool scanDirectoryByDirectory,
IDictionary<string, IList<SeriesModified>> seriesPaths, Func<IList<string>, string,Task> folderAction, Library library, bool forceCheck = false)
IDictionary<string, IList<SeriesModified>> seriesPaths, Func<IList<string>, string, string, Task> folderAction, Library library, bool forceCheck = false)
{
string normalizedPath;
var fileExtensions = string.Join("|", library.LibraryFileTypes.Select(l => l.FileTypeGroup.GetRegex()));
@ -110,12 +110,12 @@ public class ParseScannedFiles
normalizedPath = Parser.Parser.NormalizePath(directory);
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
{
await folderAction(new List<string>(), directory);
await folderAction(new List<string>(), directory, folderPath);
}
else
{
// For a scan, this is doing everything in the directory loop before the folder Action is called...which leads to no progress indication
await folderAction(_directoryService.ScanFiles(directory, fileExtensions, matcher), directory);
await folderAction(_directoryService.ScanFiles(directory, fileExtensions, matcher), directory, folderPath);
}
}
@ -125,13 +125,13 @@ public class ParseScannedFiles
normalizedPath = Parser.Parser.NormalizePath(folderPath);
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedPath, forceCheck))
{
await folderAction(new List<string>(), folderPath);
await folderAction(new List<string>(), folderPath, folderPath);
return;
}
// We need to calculate all folders till library root and see if any kavitaignores
var seriesMatcher = BuildIgnoreFromLibraryRoot(folderPath, seriesPaths);
await folderAction(_directoryService.ScanFiles(folderPath, fileExtensions, seriesMatcher), folderPath);
await folderAction(_directoryService.ScanFiles(folderPath, fileExtensions, seriesMatcher), folderPath, folderPath);
}
/// <summary>
@ -313,7 +313,7 @@ public class ParseScannedFiles
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress, MessageFactory.FileScanProgressEvent("File Scan Done", library.Name, ProgressEventType.Ended));
async Task ProcessFolder(IList<string> files, string folder)
async Task ProcessFolder(IList<string> files, string folder, string libraryRoot)
{
var normalizedFolder = Parser.Parser.NormalizePath(folder);
if (HasSeriesFolderNotChangedSinceLastScan(seriesPaths, normalizedFolder, forceCheck))
@ -342,7 +342,7 @@ public class ParseScannedFiles
var scannedSeries = new ConcurrentDictionary<ParsedSeries, List<ParserInfo>>();
var infos = files
.Select(file => _readingItemService.ParseFile(file, folder, library.Type))
.Select(file => _readingItemService.ParseFile(file, folder, libraryRoot, library.Type))
.Where(info => info != null)
.ToList();
@ -416,7 +416,7 @@ public class ParseScannedFiles
}
else
{
// TODO: I think I need to bump by 0.1f as if the prevIssue matches counter
// I need to bump by 0.1f as if the prevIssue matches counter
if (!string.IsNullOrEmpty(prevIssue) && prevIssue == counter + "")
{
// Bump by 0.1

View file

@ -0,0 +1,114 @@
using System.IO;
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
#nullable enable
/// <summary>
/// This is the basic parser for handling Manga/Comic/Book libraries. This was previously DefaultParser before splitting each parser
/// into their own classes.
/// </summary>
public class BasicParser(IDirectoryService directoryService, IDefaultParser imageParser) : DefaultParser(directoryService)
{
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
{
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 && Parser.IsCoverImage(directoryService.FileSystem.Path.GetFileName(filePath))) return null;
if (Parser.IsImage(filePath))
{
return imageParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Image, comicInfo);
}
var ret = new ParserInfo()
{
Filename = Path.GetFileName(filePath),
Format = Parser.ParseFormat(filePath),
Title = Parser.RemoveExtensionIfSupported(fileName),
FullFilePath = filePath,
Series = string.Empty,
ComicInfo = comicInfo
};
// This will be called if the epub is already parsed once then we call and merge the information, if the
if (Parser.IsEpub(filePath))
{
ret.Chapters = Parser.ParseChapter(fileName);
ret.Series = Parser.ParseSeries(fileName);
ret.Volumes = Parser.ParseVolume(fileName);
}
else
{
ret.Chapters = type == LibraryType.Comic
? Parser.ParseComicChapter(fileName)
: Parser.ParseChapter(fileName);
ret.Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName);
ret.Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName);
}
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 = Parser.ParseEdition(fileName);
if (!string.IsNullOrEmpty(edition))
{
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
ret.Edition = edition;
}
var isSpecial = type == LibraryType.Comic ? Parser.IsComicSpecial(fileName) : Parser.IsMangaSpecial(fileName);
// 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 (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && 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 (Parser.HasSpecialMarker(fileName))
{
ret.IsSpecial = true;
ret.SpecialIndex = Parser.ParseSpecialIndex(fileName);
ret.Chapters = Parser.DefaultChapter;
ret.Volumes = Parser.SpecialVolume;
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
}
if (string.IsNullOrEmpty(ret.Series))
{
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
}
// Pdfs may have .pdf in the series name, remove that
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
{
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
}
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
if (ret.IsSpecial)
{
ret.Volumes = Parser.SpecialVolume;
}
return ret.Series == string.Empty ? null : ret;
}
/// <summary>
/// Applicable for everything but ComicVine and Image library types
/// </summary>
/// <param name="filePath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override bool IsApplicable(string filePath, LibraryType type)
{
return type != LibraryType.ComicVine && type != LibraryType.Image;
}
}

View file

@ -0,0 +1,47 @@
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
public class BookParser(IDirectoryService directoryService, IBookService bookService, IDefaultParser basicParser) : DefaultParser(directoryService)
{
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null)
{
var info = bookService.ParseInfo(filePath);
if (info == null) return null;
// This catches when original library type is Manga/Comic and when parsing with non
if (Parser.ParseVolume(info.Series) != Parser.LooseLeafVolume) // Shouldn't this be info.Volume != DefaultVolume?
{
var hasVolumeInTitle = !Parser.ParseVolume(info.Title)
.Equals(Parser.LooseLeafVolume);
var hasVolumeInSeries = !Parser.ParseVolume(info.Series)
.Equals(Parser.LooseLeafVolume);
if (string.IsNullOrEmpty(info.ComicInfo?.Volume) && hasVolumeInTitle && (hasVolumeInSeries || string.IsNullOrEmpty(info.Series)))
{
// This is likely a light novel for which we can set series from parsed title
info.Series = Parser.ParseSeries(info.Title);
info.Volumes = Parser.ParseVolume(info.Title);
}
else
{
var info2 = basicParser.Parse(filePath, rootPath, libraryRoot, LibraryType.Book, comicInfo);
info.Merge(info2);
}
}
return string.IsNullOrEmpty(info.Series) ? null : info;
}
/// <summary>
/// Only applicable for Epub files
/// </summary>
/// <param name="filePath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override bool IsApplicable(string filePath, LibraryType type)
{
return Parser.IsEpub(filePath);
}
}

View file

@ -0,0 +1,130 @@
using System.IO;
using System.Linq;
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
#nullable enable
/// <summary>
/// Responsible for Parsing ComicVine Comics.
/// </summary>
/// <param name="directoryService"></param>
public class ComicVineParser(IDirectoryService directoryService) : DefaultParser(directoryService)
{
/// <summary>
/// This Parser generates Series name to be defined as Series + first Issue Volume, so "Batman (2020)".
/// </summary>
/// <param name="filePath"></param>
/// <param name="rootPath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
{
if (type != LibraryType.ComicVine) return null;
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
var info = new ParserInfo()
{
Filename = Path.GetFileName(filePath),
Format = Parser.ParseFormat(filePath),
Title = Parser.RemoveExtensionIfSupported(fileName),
FullFilePath = filePath,
Series = string.Empty,
ComicInfo = comicInfo,
Chapters = Parser.ParseComicChapter(fileName),
Volumes = Parser.ParseComicVolume(fileName)
};
// See if we can formulate the name from the ComicInfo
if (!string.IsNullOrEmpty(info.ComicInfo?.Series) && !string.IsNullOrEmpty(info.ComicInfo?.Volume))
{
info.Series = $"{info.ComicInfo.Series} ({info.ComicInfo.Volume})";
}
if (string.IsNullOrEmpty(info.Series))
{
// Check if we need to fallback to the Folder name AND that the folder matches the format "Series (Year)"
var directories = directoryService.GetFoldersTillRoot(rootPath, filePath).ToList();
if (directories.Count > 0)
{
foreach (var directory in directories)
{
if (!Parser.IsSeriesAndYear(directory)) continue;
info.Series = 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 = Parser.CleanTitle(directories[0], true, true);
}
}
else
{
if (Parser.IsSeriesAndYear(directoryName))
{
info.Series = directoryName;
info.Volumes = Parser.ParseYear(directoryName);
}
}
}
// Check if this is a Special
info.IsSpecial = Parser.IsComicSpecial(info.Filename) || Parser.IsComicSpecial(info.ComicInfo?.Format);
// Patch in other information from ComicInfo
UpdateFromComicInfo(info);
if (string.IsNullOrEmpty(info.Series))
{
info.Series = Parser.CleanTitle(directoryName, true, true);
}
return string.IsNullOrEmpty(info.Series) ? null : info;
}
/// <summary>
/// Only applicable for ComicVine library type
/// </summary>
/// <param name="filePath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override bool IsApplicable(string filePath, LibraryType type)
{
return type == LibraryType.ComicVine;
}
private void UpdateFromComicInfo(ParserInfo info)
{
if (info.ComicInfo == null) return;
if (!string.IsNullOrEmpty(info.ComicInfo.Volume))
{
info.Volumes = info.ComicInfo.Volume;
}
if (string.IsNullOrEmpty(info.Series) && !string.IsNullOrEmpty(info.ComicInfo.Series))
{
info.Series = info.ComicInfo.Series.Trim();
}
if (!string.IsNullOrEmpty(info.ComicInfo.Number))
{
info.Chapters = info.ComicInfo.Number;
if (info.IsSpecial && Parser.DefaultChapter != info.Chapters)
{
info.IsSpecial = false;
info.Volumes = $"{Parser.SpecialVolumeNumber}";
}
}
// Patch is SeriesSort from ComicInfo
if (!string.IsNullOrEmpty(info.ComicInfo.TitleSort))
{
info.SeriesSort = info.ComicInfo.TitleSort.Trim();
}
}
}

View file

@ -1,5 +1,6 @@
using System.IO;
using System.Linq;
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
@ -7,165 +8,26 @@ namespace API.Services.Tasks.Scanner.Parser;
public interface IDefaultParser
{
ParserInfo? Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga);
ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null);
void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret);
bool IsApplicable(string filePath, LibraryType type);
}
/// <summary>
/// This is an implementation of the Parser that is the basis for everything
/// </summary>
public class DefaultParser : IDefaultParser
public abstract class DefaultParser(IDirectoryService directoryService) : IDefaultParser
{
private readonly IDirectoryService _directoryService;
public DefaultParser(IDirectoryService directoryService)
{
_directoryService = directoryService;
}
/// <summary>
/// Parses information out of a file path. Will fallback to using directory name if Series couldn't be parsed
/// Parses information out of a file path. Can fallback to using directory name if Series couldn't be parsed
/// from filename.
/// </summary>
/// <param name="filePath"></param>
/// <param name="rootPath">Root folder</param>
/// <param name="type">Defaults to Manga. Allows different Regex to be used for parsing.</param>
/// <param name="type">Allows different Regex to be used for parsing.</param>
/// <returns><see cref="ParserInfo"/> or null if Series was empty</returns>
public ParserInfo? Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga)
{
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 && Parser.IsCoverImage(_directoryService.FileSystem.Path.GetFileName(filePath))) return null;
var ret = new ParserInfo()
{
Filename = Path.GetFileName(filePath),
Format = Parser.ParseFormat(filePath),
Title = Path.GetFileNameWithoutExtension(fileName),
FullFilePath = filePath,
Series = string.Empty
};
// If library type is Image or this is not a cover image in a non-image library, then use dedicated parsing mechanism
if (type == LibraryType.Image || Parser.IsImage(filePath))
{
// TODO: We can move this up one level
return ParseImage(filePath, rootPath, ret);
}
// This will be called if the epub is already parsed once then we call and merge the information, if the
if (Parser.IsEpub(filePath))
{
ret.Chapters = Parser.ParseChapter(fileName);
ret.Series = Parser.ParseSeries(fileName);
ret.Volumes = Parser.ParseVolume(fileName);
}
else
{
ret.Chapters = type == LibraryType.Comic
? Parser.ParseComicChapter(fileName)
: Parser.ParseChapter(fileName);
ret.Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName);
ret.Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName);
}
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 = Parser.ParseEdition(fileName);
if (!string.IsNullOrEmpty(edition))
{
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
ret.Edition = edition;
}
var isSpecial = type == LibraryType.Comic ? Parser.IsComicSpecial(fileName) : Parser.IsMangaSpecial(fileName);
// 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 (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && 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 (Parser.HasSpecialMarker(fileName))
{
ret.IsSpecial = true;
ret.SpecialIndex = Parser.ParseSpecialIndex(fileName);
ret.Chapters = Parser.DefaultChapter;
ret.Volumes = Parser.LooseLeafVolume;
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
}
if (string.IsNullOrEmpty(ret.Series))
{
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
}
// Pdfs may have .pdf in the series name, remove that
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
{
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
}
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
if (ret.IsSpecial)
{
ret.Volumes = $"{Parser.SpecialVolumeNumber}";
}
return ret.Series == string.Empty ? null : ret;
}
private ParserInfo ParseImage(string filePath, string rootPath, ParserInfo ret)
{
ret.Volumes = Parser.LooseLeafVolume;
ret.Chapters = Parser.DefaultChapter;
var directoryName = _directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
ret.Series = directoryName;
ParseFromFallbackFolders(filePath, rootPath, LibraryType.Image, ref ret);
if (IsEmptyOrDefault(ret.Volumes, ret.Chapters))
{
ret.IsSpecial = true;
}
else
{
var parsedVolume = Parser.ParseVolume(ret.Filename);
var parsedChapter = Parser.ParseChapter(ret.Filename);
if (IsEmptyOrDefault(ret.Volumes, string.Empty) && !parsedVolume.Equals(Parser.LooseLeafVolume))
{
ret.Volumes = parsedVolume;
}
if (IsEmptyOrDefault(string.Empty, ret.Chapters) && !parsedChapter.Equals(Parser.DefaultChapter))
{
ret.Chapters = parsedChapter;
}
}
// 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 = Parser.CleanTitle(directoryName, replaceSpecials: false);
}
return ret;
}
private static bool IsEmptyOrDefault(string volumes, string chapters)
{
return (string.IsNullOrEmpty(chapters) || chapters == Parser.DefaultChapter) &&
(string.IsNullOrEmpty(volumes) || volumes == Parser.LooseLeafVolume);
}
public abstract ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null);
/// <summary>
/// Fills out <see cref="ParserInfo"/> by trying to parse volume, chapters, and series from folders
@ -176,13 +38,13 @@ public class DefaultParser : IDefaultParser
/// <param name="ret">Expects a non-null ParserInfo which this method will populate</param>
public void ParseFromFallbackFolders(string filePath, string rootPath, LibraryType type, ref ParserInfo ret)
{
var fallbackFolders = _directoryService.GetFoldersTillRoot(rootPath, filePath)
var fallbackFolders = directoryService.GetFoldersTillRoot(rootPath, filePath)
.Where(f => !Parser.IsMangaSpecial(f))
.ToList();
if (fallbackFolders.Count == 0)
{
var rootFolderName = _directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
var rootFolderName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
var series = Parser.ParseSeries(rootFolderName);
if (string.IsNullOrEmpty(series))
@ -236,4 +98,12 @@ public class DefaultParser : IDefaultParser
}
}
}
public abstract bool IsApplicable(string filePath, LibraryType type);
protected static bool IsEmptyOrDefault(string volumes, string chapters)
{
return (string.IsNullOrEmpty(chapters) || chapters == Parser.DefaultChapter) &&
(string.IsNullOrEmpty(volumes) || volumes == Parser.LooseLeafVolume);
}
}

View file

@ -0,0 +1,54 @@
using System.IO;
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
#nullable enable
public class ImageParser(IDirectoryService directoryService) : DefaultParser(directoryService)
{
public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo? comicInfo = null)
{
if (type != LibraryType.Image || !Parser.IsImage(filePath)) return null;
var directoryName = directoryService.FileSystem.DirectoryInfo.New(rootPath).Name;
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
var ret = new ParserInfo
{
Series = directoryName,
Volumes = Parser.LooseLeafVolume,
Chapters = Parser.DefaultChapter,
ComicInfo = comicInfo,
Format = MangaFormat.Image,
Filename = Path.GetFileName(filePath),
FullFilePath = filePath,
Title = fileName,
};
ParseFromFallbackFolders(filePath, libraryRoot, LibraryType.Image, ref ret);
if (IsEmptyOrDefault(ret.Volumes, ret.Chapters))
{
ret.IsSpecial = true;
ret.Volumes = $"{Parser.SpecialVolumeNumber}";
}
// 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 = Parser.CleanTitle(directoryName, replaceSpecials: false);
}
return string.IsNullOrEmpty(ret.Series) ? null : ret;
}
/// <summary>
/// Only applicable for Image files and Image library type
/// </summary>
/// <param name="filePath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override bool IsApplicable(string filePath, LibraryType type)
{
return type == LibraryType.Image && Parser.IsImage(filePath);
}
}

View file

@ -106,6 +106,12 @@ public static class Parser
private static readonly Regex NormalizeRegex = new Regex(@"[^\p{L}0-9\+!]",
MatchOptions, RegexTimeout);
/// <summary>
/// Supports Batman (2020) or Batman (2)
/// </summary>
private static readonly Regex SeriesAndYearRegex = new Regex(@"^\D+\s\((?<Year>\d+)\)$",
MatchOptions, RegexTimeout);
/// <summary>
/// Recognizes the Special token only
/// </summary>
@ -698,8 +704,9 @@ public static class Parser
return MangaSpecialRegex.IsMatch(filePath);
}
public static bool IsComicSpecial(string filePath)
public static bool IsComicSpecial(string? filePath)
{
if (string.IsNullOrEmpty(filePath)) return false;
filePath = ReplaceUnderscores(filePath);
return ComicSpecialRegex.IsMatch(filePath);
}
@ -1125,13 +1132,32 @@ public static class Parser
// NOTE: This is failing for //localhost:5000/api/book/29919/book-resources?file=OPS/images/tick1.jpg
var importFile = match.Groups["Filename"].Value;
if (!importFile.Contains("?")) return importFile;
if (!importFile.Contains('?')) return importFile;
}
return null;
}
public static string RemoveExtensionIfSupported(string? filename)
/// <summary>
/// If the name matches exactly Series (Volume digits)
/// </summary>
/// <param name="name"></param>
/// <returns></returns>
public static bool IsSeriesAndYear(string? name)
{
return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name);
}
public static string ParseYear(string? name)
{
if (string.IsNullOrEmpty(name)) return string.Empty;
var match = SeriesAndYearRegex.Match(name);
if (!match.Success) return string.Empty;
return match.Groups["Year"].Value;
}
public static string? RemoveExtensionIfSupported(string? filename)
{
if (string.IsNullOrEmpty(filename)) return filename;

View file

@ -0,0 +1,100 @@
using System.IO;
using API.Data.Metadata;
using API.Entities.Enums;
namespace API.Services.Tasks.Scanner.Parser;
public class PdfParser(IDirectoryService directoryService) : DefaultParser(directoryService)
{
public override ParserInfo Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, ComicInfo comicInfo = null)
{
var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath);
var ret = new ParserInfo
{
Filename = Path.GetFileName(filePath),
Format = Parser.ParseFormat(filePath),
Title = Parser.RemoveExtensionIfSupported(fileName)!,
FullFilePath = filePath,
Series = string.Empty,
ComicInfo = comicInfo,
Chapters = type == LibraryType.Comic
? Parser.ParseComicChapter(fileName)
: Parser.ParseChapter(fileName)
};
ret.Series = type == LibraryType.Comic ? Parser.ParseComicSeries(fileName) : Parser.ParseSeries(fileName);
ret.Volumes = type == LibraryType.Comic ? Parser.ParseComicVolume(fileName) : Parser.ParseVolume(fileName);
if (ret.Series == string.Empty)
{
// Try to parse information out of each folder all the way to rootPath
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
}
var edition = Parser.ParseEdition(fileName);
if (!string.IsNullOrEmpty(edition))
{
ret.Series = Parser.CleanTitle(ret.Series.Replace(edition, string.Empty), type is LibraryType.Comic);
ret.Edition = edition;
}
var isSpecial = type == LibraryType.Comic ? Parser.IsComicSpecial(fileName) : Parser.IsMangaSpecial(fileName);
// 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 (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && isSpecial)
{
ret.IsSpecial = true;
// NOTE: This can cause some complications, we should try to be a bit less aggressive to fallback to folder
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
}
// 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 (Parser.HasSpecialMarker(fileName))
{
ret.IsSpecial = true;
ret.SpecialIndex = Parser.ParseSpecialIndex(fileName);
ret.Chapters = Parser.DefaultChapter;
ret.Volumes = Parser.SpecialVolume;
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
}
if (ret.Chapters == Parser.DefaultChapter && ret.Volumes == Parser.LooseLeafVolume && type == LibraryType.Book)
{
ret.IsSpecial = true;
ret.Chapters = Parser.DefaultChapter;
ret.Volumes = Parser.SpecialVolume;
ParseFromFallbackFolders(filePath, rootPath, type, ref ret);
}
if (string.IsNullOrEmpty(ret.Series))
{
ret.Series = Parser.CleanTitle(fileName, type is LibraryType.Comic);
}
// Pdfs may have .pdf in the series name, remove that
if (Parser.IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf"))
{
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
}
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
if (ret.IsSpecial)
{
ret.Volumes = $"{Parser.SpecialVolumeNumber}";
}
return string.IsNullOrEmpty(ret.Series) ? null : ret;
}
/// <summary>
/// Only applicable for PDF files
/// </summary>
/// <param name="filePath"></param>
/// <param name="type"></param>
/// <returns></returns>
public override bool IsApplicable(string filePath, LibraryType type)
{
return Parser.IsPdf(filePath);
}
}

View file

@ -439,6 +439,14 @@ public class ProcessSeries : IProcessSeries
}
}
if (!series.Metadata.ImprintLocked)
{
foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Imprint))
{
PersonHelper.AddPersonIfNotExists(series.Metadata.People, person);
}
}
if (!series.Metadata.LettererLocked)
{
foreach (var person in chapter.People.Where(p => p.Role == PersonRole.Letterer))
@ -496,6 +504,9 @@ public class ProcessSeries : IProcessSeries
case PersonRole.Inker:
if (!series.Metadata.InkerLocked) series.Metadata.People.Remove(person);
break;
case PersonRole.Imprint:
if (!series.Metadata.ImprintLocked) series.Metadata.People.Remove(person);
break;
case PersonRole.Colorist:
if (!series.Metadata.ColoristLocked) series.Metadata.People.Remove(person);
break;
@ -847,6 +858,10 @@ public class ProcessSeries : IProcessSeries
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Publisher);
UpdatePeople(people, PersonRole.Publisher, AddPerson);
people = GetTagValues(comicInfo.Imprint);
PersonHelper.RemovePeople(chapter.People, people, PersonRole.Imprint);
UpdatePeople(people, PersonRole.Imprint, AddPerson);
var genres = GetTagValues(comicInfo.Genre);
GenreHelper.KeepOnlySameGenreBetweenLists(chapter.Genres,
genres.Select(g => new GenreBuilder(g).Build()).ToList());

View file

@ -5,7 +5,8 @@ export enum LibraryType {
Comic = 1,
Book = 2,
Images = 3,
LightNovel = 4
LightNovel = 4,
ComicVine = 5
}
export interface Library {

View file

@ -29,6 +29,7 @@ export interface ChapterMetadata {
characters: Array<Person>;
pencillers: Array<Person>;
inkers: Array<Person>;
imprints: Array<Person>;
colorists: Array<Person>;
letterers: Array<Person>;
editors: Array<Person>;

View file

@ -10,7 +10,8 @@ export enum PersonRole {
Editor = 9,
Publisher = 10,
Character = 11,
Translator = 12
Translator = 12,
Imprint = 13
}
export interface Person {

View file

@ -21,6 +21,7 @@ export interface SeriesMetadata {
characters: Array<Person>;
pencillers: Array<Person>;
inkers: Array<Person>;
imprints: Array<Person>;
colorists: Array<Person>;
letterers: Array<Person>;
editors: Array<Person>;
@ -40,6 +41,7 @@ export interface SeriesMetadata {
characterLocked: boolean;
pencillerLocked: boolean;
inkerLocked: boolean;
imprintLocked: boolean;
coloristLocked: boolean;
lettererLocked: boolean;
editorLocked: boolean;

View file

@ -29,7 +29,8 @@ export enum FilterField
FilePath = 25,
WantToRead = 26,
ReadingDate = 27,
AverageRating = 28
AverageRating = 28,
Imprint = 29
}

View file

@ -15,4 +15,5 @@ export interface RelatedSeries {
doujinshis: Array<Series>;
parent: Array<Series>;
editions: Array<Series>;
annuals: Array<Series>;
}

View file

@ -14,7 +14,8 @@ export enum RelationKind {
* This is UI only. Backend will generate Parent series for everything but Prequel/Sequel
*/
Parent = 12,
Edition = 13
Edition = 13,
Annual = 14
}
const RelationKindsUnsorted = [
@ -22,6 +23,7 @@ const RelationKindsUnsorted = [
{text: 'Sequel', value: RelationKind.Sequel},
{text: 'Spin Off', value: RelationKind.SpinOff},
{text: 'Adaptation', value: RelationKind.Adaptation},
{text: 'Annual', value: RelationKind.Annual},
{text: 'Alternative Setting', value: RelationKind.AlternativeSetting},
{text: 'Alternative Version', value: RelationKind.AlternativeVersion},
{text: 'Side Story', value: RelationKind.SideStory},

View file

@ -28,6 +28,8 @@ export class FilterFieldPipe implements PipeTransform {
return translate('filter-field-pipe.genres');
case FilterField.Inker:
return translate('filter-field-pipe.inker');
case FilterField.Imprint:
return translate('filter-field-pipe.imprint');
case FilterField.Languages:
return translate('filter-field-pipe.languages');
case FilterField.Libraries:

View file

@ -29,6 +29,8 @@ export class PersonRolePipe implements PipeTransform {
return this.translocoService.translate('person-role-pipe.penciller');
case PersonRole.Publisher:
return this.translocoService.translate('person-role-pipe.publisher');
case PersonRole.Imprint:
return this.translocoService.translate('person-role-pipe.imprint');
case PersonRole.Writer:
return this.translocoService.translate('person-role-pipe.writer');
case PersonRole.Other:

View file

@ -39,6 +39,8 @@ export class RelationshipPipe implements PipeTransform {
return this.translocoService.translate('relationship-pipe.parent');
case RelationKind.Edition:
return this.translocoService.translate('relationship-pipe.edition');
case RelationKind.Annual:
return this.translocoService.translate('relationship-pipe.annual');
default:
return '';
}

View file

@ -199,10 +199,11 @@ export class SeriesService {
updateRelationships(seriesId: number, adaptations: Array<number>, characters: Array<number>,
contains: Array<number>, others: Array<number>, prequels: Array<number>,
sequels: Array<number>, sideStories: Array<number>, spinOffs: Array<number>,
alternativeSettings: Array<number>, alternativeVersions: Array<number>, doujinshis: Array<number>, editions: Array<number>) {
alternativeSettings: Array<number>, alternativeVersions: Array<number>,
doujinshis: Array<number>, editions: Array<number>, annuals: Array<number>) {
return this.httpClient.post(this.baseUrl + 'series/update-related?seriesId=' + seriesId,
{seriesId, adaptations, characters, sequels, prequels, contains, others, sideStories, spinOffs,
alternativeSettings, alternativeVersions, doujinshis, editions});
alternativeSettings, alternativeVersions, doujinshis, editions, annuals});
}
getSeriesDetail(seriesId: number) {

View file

@ -229,6 +229,23 @@
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="imprint" class="form-label">{{t('imprint-label')}}</label>
<app-typeahead (selectedData)="updatePerson($event, PersonRole.Imprint);metadata.publisherLocked = true" [settings]="getPersonsSettings(PersonRole.Imprint)"
[(locked)]="metadata.imprintLocked" (onUnlock)="metadata.imprintLocked = false"
(newItemAdded)="metadata.imprintLocked = true">
<ng-template #badgeItem let-item let-position="idx">
{{item.name}}
</ng-template>
<ng-template #optionItem let-item let-position="idx">
{{item.name}}
</ng-template>
</app-typeahead>
</div>
</div>
<div class="row g-0">
<div class="mb-3">
<label for="penciller" class="form-label">{{t('penciller-label')}}</label>

View file

@ -487,6 +487,7 @@ export class EditSeriesModalComponent implements OnInit {
this.updateFromPreset('letterer', this.metadata.letterers, PersonRole.Letterer),
this.updateFromPreset('penciller', this.metadata.pencillers, PersonRole.Penciller),
this.updateFromPreset('publisher', this.metadata.publishers, PersonRole.Publisher),
this.updateFromPreset('imprint', this.metadata.imprints, PersonRole.Imprint),
this.updateFromPreset('translator', this.metadata.translators, PersonRole.Translator)
]).pipe(map(results => {
return of(true);
@ -634,6 +635,9 @@ export class EditSeriesModalComponent implements OnInit {
case PersonRole.Publisher:
this.metadata.publishers = persons;
break;
case PersonRole.Imprint:
this.metadata.imprints = persons;
break;
case PersonRole.Writer:
this.metadata.writers = persons;
break;

View file

@ -4,10 +4,11 @@
&& chapter.pencillers.length === 0 && chapter.inkers.length === 0
&& chapter.colorists.length === 0 && chapter.letterers.length === 0
&& chapter.editors.length === 0 && chapter.publishers.length === 0
&& chapter.characters.length === 0 && chapter.translators.length === 0">
&& chapter.characters.length === 0 && chapter.translators.length === 0
&& chapter.imprints.length === 0">
{{t('no-data')}}
</span>
<div class="row g-0">
<div class="container-flex row row-cols-auto row-cols-lg-5 g-2 g-lg-3 me-0 mt-2">
<div class="col-auto mt-2" *ngIf="chapter.writers && chapter.writers.length > 0">
<h6>{{t('writers-title')}}</h6>
<app-badge-expander [items]="chapter.writers">
@ -81,6 +82,15 @@
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.imprints && chapter.imprints.length > 0">
<h6>{{t('imprints-title')}}</h6>
<app-badge-expander [items]="chapter.imprints">
<ng-template #badgeExpanderItem let-item let-position="idx">
<app-person-badge [person]="item"></app-person-badge>
</ng-template>
</app-badge-expander>
</div>
<div class="col-auto mt-2" *ngIf="chapter.characters && chapter.characters.length > 0">
<h6>{{t('characters-title')}}</h6>
<app-badge-expander [items]="chapter.characters">

View file

@ -71,7 +71,7 @@ export class EditSeriesRelationComponent implements OnInit {
focusTypeahead = new EventEmitter();
ngOnInit(): void {
this.seriesService.getRelatedForSeries(this.series.id).subscribe(async relations => {
this.seriesService.getRelatedForSeries(this.series.id).subscribe( relations => {
this.setupRelationRows(relations.prequels, RelationKind.Prequel);
this.setupRelationRows(relations.sequels, RelationKind.Sequel);
this.setupRelationRows(relations.sideStories, RelationKind.SideStory);
@ -85,6 +85,7 @@ export class EditSeriesRelationComponent implements OnInit {
this.setupRelationRows(relations.contains, RelationKind.Contains);
this.setupRelationRows(relations.parent, RelationKind.Parent);
this.setupRelationRows(relations.editions, RelationKind.Edition);
this.setupRelationRows(relations.annuals, RelationKind.Annual);
this.cdRef.detectChanges();
});
@ -181,9 +182,10 @@ export class EditSeriesRelationComponent implements OnInit {
const alternativeVersions = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.AlternativeVersion && item.series !== undefined).map(item => item.series!.id);
const doujinshis = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Doujinshi && item.series !== undefined).map(item => item.series!.id);
const editions = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Edition && item.series !== undefined).map(item => item.series!.id);
const annuals = this.relations.filter(item => (parseInt(item.formControl.value, 10) as RelationKind) === RelationKind.Annual && item.series !== undefined).map(item => item.series!.id);
// NOTE: We can actually emit this onto an observable and in main parent, use mergeMap into the forkJoin
this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis, editions).subscribe(() => {});
this.seriesService.updateRelationships(this.series.id, adaptations, characters, contains, others, prequels, sequels, sideStories, spinOffs, alternativeSettings, alternativeVersions, doujinshis, editions, annuals).subscribe(() => {});
}

View file

@ -12,6 +12,20 @@
{{Number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + Number : volumeTitle) : t('special')}}
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.ComicVine">
<ng-container *ngIf="titleName !== '' && prioritizeTitleName; else fullComicTitle">
{{titleName}}
</ng-container>
<ng-template #fullComicTitle>
{{seriesName.length > 0 ? seriesName + ' - ' : ''}}
<ng-container *ngIf="includeVolume && volumeTitle !== ''">
{{Number !== LooseLeafOrSpecial ? (isChapter && includeVolume ? volumeTitle : '') : ''}}
</ng-container>
{{Number !== LooseLeafOrSpecial ? (isChapter ? t('issue-num') + Number : volumeTitle) : t('special')}}
</ng-template>
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Manga">
<ng-container *ngIf="titleName !== '' && prioritizeTitleName; else fullMangaTitle">
{{titleName}}
@ -30,5 +44,8 @@
<ng-container *ngSwitchCase="LibraryType.LightNovel">
{{volumeTitle}}
</ng-container>
<ng-container *ngSwitchCase="LibraryType.Images">
{{Number !== LooseLeafOrSpecial ? (isChapter ? (t('chapter') + ' ') + Number : volumeTitle) : t('special')}}
</ng-container>
</ng-container>
</ng-container>

View file

@ -64,7 +64,8 @@ const DropdownFields = [FilterField.PublicationStatus, FilterField.Languages, Fi
FilterField.Editor, FilterField.CoverArtist, FilterField.Letterer,
FilterField.Colorist, FilterField.Inker, FilterField.Penciller,
FilterField.Writers, FilterField.Genres, FilterField.Libraries,
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags
FilterField.Formats, FilterField.CollectionTags, FilterField.Tags,
FilterField.Imprint
];
const BooleanFields = [FilterField.WantToRead];
const DateFields = [FilterField.ReadingDate];
@ -297,6 +298,7 @@ export class MetadataFilterRowComponent implements OnInit {
case FilterField.Letterer: return this.getPersonOptions(PersonRole.Letterer);
case FilterField.Penciller: return this.getPersonOptions(PersonRole.Penciller);
case FilterField.Publisher: return this.getPersonOptions(PersonRole.Publisher);
case FilterField.Imprint: return this.getPersonOptions(PersonRole.Imprint);
case FilterField.Translators: return this.getPersonOptions(PersonRole.Translator);
case FilterField.Writers: return this.getPersonOptions(PersonRole.Writer);
}

View file

@ -178,6 +178,9 @@ export class NavHeaderComponent implements OnInit {
case PersonRole.Publisher:
this.goTo({field: FilterField.Publisher, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Imprint:
this.goTo({field: FilterField.Imprint, comparison: FilterComparison.Equal, value: filter});
break;
case PersonRole.Translator:
this.goTo({field: FilterField.Translators, comparison: FilterComparison.Equal, value: filter});
break;

View file

@ -55,7 +55,7 @@ import {
import {TagBadgeCursor} from 'src/app/shared/tag-badge/tag-badge.component';
import {DownloadEvent, DownloadService} from 'src/app/shared/_services/download.service';
import {KEY_CODES, UtilityService} from 'src/app/shared/_services/utility.service';
import {Chapter, SpecialVolumeNumber} from 'src/app/_models/chapter';
import {Chapter, LooseLeafOrDefaultNumber, SpecialVolumeNumber} from 'src/app/_models/chapter';
import {Device} from 'src/app/_models/device/device';
import {ScanSeriesEvent} from 'src/app/_models/events/scan-series-event';
import {SeriesRemovedEvent} from 'src/app/_models/events/series-removed-event';
@ -67,7 +67,6 @@ import {RelationKind} from 'src/app/_models/series-detail/relation-kind';
import {SeriesMetadata} from 'src/app/_models/metadata/series-metadata';
import {User} from 'src/app/_models/user';
import {Volume} from 'src/app/_models/volume';
import {LooseLeafOrDefaultNumber} from 'src/app/_models/chapter';
import {AccountService} from 'src/app/_services/account.service';
import {Action, ActionFactoryService, ActionItem} from 'src/app/_services/action-factory.service';
import {ActionService} from 'src/app/_services/action.service';
@ -339,12 +338,20 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
}
get ShowStorylineTab() {
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel) && (this.volumes.length > 0 || this.chapters.length > 0);
if (this.libraryType === LibraryType.ComicVine) return false;
return (this.libraryType !== LibraryType.Book && this.libraryType !== LibraryType.LightNovel && this.libraryType !== LibraryType.Comic)
&& (this.volumes.length > 0 || this.chapters.length > 0);
}
get ShowVolumeTab() {
if (this.libraryType === LibraryType.ComicVine) {
if (this.volumes.length > 1) return true;
if (this.specials.length === 0 && this.chapters.length === 0) return true;
return false;
}
return this.volumes.length > 0;
}
get ShowChaptersTab() {
return this.chapters.length > 0;
}
@ -662,6 +669,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
...relations.doujinshis.map(item => this.createRelatedSeries(item, RelationKind.Doujinshi)),
...relations.parent.map(item => this.createRelatedSeries(item, RelationKind.Parent)),
...relations.editions.map(item => this.createRelatedSeries(item, RelationKind.Edition)),
...relations.annuals.map(item => this.createRelatedSeries(item, RelationKind.Annual)),
];
if (this.relations.length > 0) {
this.hasRelations = true;
@ -729,9 +737,18 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
if (this.volumes.length === 0 && this.chapters.length === 0 && this.specials.length > 0) {
this.activeTabId = TabID.Specials;
} else {
if (this.libraryType == LibraryType.Comic || this.libraryType == LibraryType.ComicVine) {
if (this.chapters.length === 0) {
this.activeTabId = TabID.Specials;
} else {
this.activeTabId = TabID.Chapters;
}
} else {
this.activeTabId = TabID.Storyline;
}
}
this.cdRef.markForCheck();
}

View file

@ -154,6 +154,12 @@
</ng-template>
</app-metadata-detail>
<app-metadata-detail [tags]="seriesMetadata.imprints" [libraryId]="series.libraryId" [queryParam]="FilterField.Imprint" [heading]="t('imprints-title')">
<ng-template #itemTemplate let-item>
<app-person-badge a11y-click="13,32" class="col-auto" [person]="item"></app-person-badge>
</ng-template>
</app-metadata-detail>
</div>
<div class="row g-0">

View file

@ -4,7 +4,7 @@ import {
Component,
inject,
Input,
OnChanges,
OnChanges, OnInit,
SimpleChanges,
ViewEncapsulation
} from '@angular/core';
@ -46,7 +46,7 @@ import {Rating} from "../../../_models/rating";
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class SeriesMetadataDetailComponent implements OnChanges {
export class SeriesMetadataDetailComponent implements OnChanges, OnInit {
protected readonly imageService = inject(ImageService);
protected readonly utilityService = inject(UtilityService);
@ -83,13 +83,26 @@ export class SeriesMetadataDetailComponent implements OnChanges {
return this.seriesMetadata?.webLinks.split(',') || [];
}
constructor() {
ngOnInit() {
// If on desktop, we can just have all the data expanded by default:
this.isCollapsed = this.utilityService.getActiveBreakpoint() < Breakpoint.Desktop;
// Check if there is a lot of extended data, if so, re-collapse
const sum = (this.seriesMetadata.colorists.length + this.seriesMetadata.editors.length
+ this.seriesMetadata.coverArtists.length + this.seriesMetadata.inkers.length
+ this.seriesMetadata.letterers.length + this.seriesMetadata.pencillers.length
+ this.seriesMetadata.publishers.length + this.seriesMetadata.characters.length
+ this.seriesMetadata.imprints.length + this.seriesMetadata.translators.length
+ this.seriesMetadata.writers.length) / 11;
if (sum > 10) {
this.isCollapsed = true;
}
this.cdRef.markForCheck();
}
ngOnChanges(changes: SimpleChanges): void {
this.hasExtendedProperties = this.seriesMetadata.colorists.length > 0 ||
this.seriesMetadata.editors.length > 0 ||
this.seriesMetadata.coverArtists.length > 0 ||
@ -98,6 +111,7 @@ export class SeriesMetadataDetailComponent implements OnChanges {
this.seriesMetadata.pencillers.length > 0 ||
this.seriesMetadata.publishers.length > 0 ||
this.seriesMetadata.characters.length > 0 ||
this.seriesMetadata.imprints.length > 0 ||
this.seriesMetadata.translators.length > 0;

View file

@ -67,6 +67,7 @@ export class UtilityService {
case LibraryType.LightNovel:
return this.translocoService.translate('common.book-num') + (includeSpace ? ' ' : '');
case LibraryType.Comic:
case LibraryType.ComicVine:
if (includeHash) {
return this.translocoService.translate('common.issue-hash-num');
}

View file

@ -188,6 +188,7 @@ export class SideNavComponent implements OnInit {
case LibraryType.LightNovel:
return 'fa-book';
case LibraryType.Comic:
case LibraryType.ComicVine:
case LibraryType.Manga:
return 'fa-book-open';
case LibraryType.Images:

View file

@ -459,7 +459,8 @@
"side-story": "Side Story",
"spin-off": "Spin Off",
"parent": "Parent",
"edition": "Edition"
"edition": "Edition",
"annual": "Annual"
},
"publication-status-pipe": {
@ -481,7 +482,8 @@
"penciller": "Penciller",
"publisher": "Publisher",
"writer": "Writer",
"other": "Other"
"other": "Other",
"imprint": "Imprint"
},
"manga-format-pipe": {
@ -755,6 +757,7 @@
"translators-title": "Translators",
"pencillers-title": "Pencillers",
"publishers-title": "Publishers",
"imprints-title": "Imprints",
"promoted": "{{common.promoted}}",
"see-more": "See More",
@ -930,6 +933,7 @@
"writers-title": "{{series-metadata-detail.writers-title}}",
"genres-title": "{{series-metadata-detail.genres-title}}",
"publishers-title": "{{series-metadata-detail.publishers-title}}",
"imprints-title": "{{series-metadata-detail.imprints-title}}",
"tags-title": "{{series-metadata-detail.tags-title}}",
"not-defined": "Not defined",
"read": "{{common.read}}",
@ -1683,6 +1687,7 @@
"cover-artist-label": "Cover Artist",
"writer-label": "Writer",
"publisher-label": "Publisher",
"imprint-label": "Imprint",
"penciller-label": "Penciller",
"letterer-label": "Letterer",
"inker-label": "Inker",
@ -1944,6 +1949,7 @@
"letterer": "Letterer",
"publication-status": "Publication Status",
"penciller": "Penciller",
"imprint": "Imprint",
"publisher": "Publisher",
"read-progress": "Read Progress",
"read-time": "Read Time",

View file

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.14.5"
"version": "0.7.14.6"
},
"servers": [
{
@ -2909,7 +2909,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -2922,7 +2923,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -2935,7 +2937,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -3200,7 +3203,8 @@
9,
10,
11,
12
12,
13
],
"type": "integer",
"format": "int32"
@ -3619,7 +3623,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -9191,7 +9196,8 @@
10,
11,
12,
13
13,
14
],
"type": "integer",
"description": "Represents a relationship between Series",
@ -13526,7 +13532,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -14150,7 +14157,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"description": "Library type",
@ -14273,6 +14281,13 @@
},
"nullable": true
},
"imprints": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
},
"nullable": true
},
"colorists": {
"type": "array",
"items": {
@ -15599,7 +15614,8 @@
25,
26,
27,
28
28,
29
],
"type": "integer",
"description": "Represents the field which will dictate the value type and the Extension used for filtering",
@ -15916,7 +15932,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -16030,7 +16047,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -16529,7 +16547,8 @@
9,
10,
11,
12
12,
13
],
"type": "integer",
"format": "int32"
@ -16574,7 +16593,8 @@
9,
10,
11,
12
12,
13
],
"type": "integer",
"format": "int32"
@ -17025,7 +17045,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -17078,7 +17099,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -17286,6 +17308,13 @@
"$ref": "#/components/schemas/SeriesDto"
},
"nullable": true
},
"annuals": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SeriesDto"
},
"nullable": true
}
},
"additionalProperties": false
@ -18217,6 +18246,9 @@
"inkerLocked": {
"type": "boolean"
},
"imprintLocked": {
"type": "boolean"
},
"lettererLocked": {
"type": "boolean"
},
@ -18327,6 +18359,13 @@
},
"nullable": true
},
"imprints": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonDto"
},
"nullable": true
},
"colorists": {
"type": "array",
"items": {
@ -18450,6 +18489,9 @@
"inkerLocked": {
"type": "boolean"
},
"imprintLocked": {
"type": "boolean"
},
"lettererLocked": {
"type": "boolean"
},
@ -18496,7 +18538,8 @@
10,
11,
12,
13
13,
14
],
"type": "integer",
"description": "Represents a relationship between Series",
@ -19417,7 +19460,8 @@
1,
2,
3,
4
4,
5
],
"type": "integer",
"format": "int32"
@ -19841,6 +19885,14 @@
"format": "int32"
},
"nullable": true
},
"annuals": {
"type": "array",
"items": {
"type": "integer",
"format": "int32"
},
"nullable": true
}
},
"additionalProperties": false