diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index ec9c1884f..798f388b2 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -10,8 +10,8 @@ - - + + diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index a571a6e72..ccd26444a 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -6,13 +6,13 @@ - + - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/API.Tests/Parsers/DefaultParserTests.cs b/API.Tests/Parsers/DefaultParserTests.cs index 244c08b97..ffe14a7c3 100644 --- a/API.Tests/Parsers/DefaultParserTests.cs +++ b/API.Tests/Parsers/DefaultParserTests.cs @@ -46,8 +46,8 @@ public class DefaultParserTests [Theory] [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", new [] {"Btooom!", "1", "1"})] [InlineData("/manga/Btooom!/Vol.1 Chapter 2/1.cbz", new [] {"Btooom!", "1", "2"})] - [InlineData("/manga/Monster/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", new [] {"Monster", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, "1"})] - [InlineData("/manga/Hajime no Ippo/Artbook/Hajime no Ippo - Artbook.cbz", new [] {"Hajime no Ippo", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter})] + [InlineData("/manga/Monster/Ch. 001-016 [MangaPlus] [Digital] [amit34521]/Monster Ch. 001 [MangaPlus] [Digital] [amit34521]/13.jpg", new [] {"Monster", Parser.LooseLeafVolume, "1"})] + [InlineData("/manga/Hajime no Ippo/Artbook/Hajime no Ippo - Artbook.cbz", new [] {"Hajime no Ippo", Parser.LooseLeafVolume, Parser.DefaultChapter})] public void ParseFromFallbackFolders_ShouldParseSeriesVolumeAndChapter(string inputFile, string[] expectedParseInfo) { const string rootDirectory = "/manga/"; @@ -119,7 +119,7 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen", Volumes = "1", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Vol 1.cbz", Format = MangaFormat.Archive, + Chapters = Parser.DefaultChapter, Filename = "Vol 1.cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -144,7 +144,7 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Tenjo Tenge {Full Contact Edition}", Volumes = "1", Edition = "", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, + Chapters = Parser.DefaultChapter, Filename = "Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -152,7 +152,7 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Akame ga KILL! ZERO", Volumes = "1", Edition = "", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, + Chapters = Parser.DefaultChapter, Filename = "Akame ga KILL! ZERO v01 (2016) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -160,14 +160,14 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Dorohedoro", Volumes = "1", Edition = "", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, + Chapters = Parser.DefaultChapter, Filename = "Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); filepath = @"E:/Manga/APOSIMZ/APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz"; expected.Add(filepath, new ParserInfo { - Series = "APOSIMZ", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "APOSIMZ", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "40", Filename = "APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -175,7 +175,7 @@ public class DefaultParserTests filepath = @"E:/Manga/Corpse Party Musume/Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz"; expected.Add(filepath, new ParserInfo { - Series = "Kedouin Makoto - Corpse Party Musume", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "Kedouin Makoto - Corpse Party Musume", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "9", Filename = "Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -183,7 +183,7 @@ public class DefaultParserTests filepath = @"E:/Manga/Goblin Slayer/Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz"; expected.Add(filepath, new ParserInfo { - Series = "Goblin Slayer - Brand New Day", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "Goblin Slayer - Brand New Day", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "6.5", Filename = "Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -191,15 +191,15 @@ public class DefaultParserTests filepath = @"E:/Manga/Summer Time Rendering/Specials/Record 014 (between chapter 083 and ch084) SP11.cbr"; expected.Add(filepath, new ParserInfo { - Series = "Summer Time Rendering", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, Edition = "", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Record 014 (between chapter 083 and ch084) SP11.cbr", Format = MangaFormat.Archive, + Series = "Summer Time Rendering", Volumes = Parser.SpecialVolume, Edition = "", + Chapters = Parser.DefaultChapter, Filename = "Record 014 (between chapter 083 and ch084) SP11.cbr", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = true }); filepath = @"E:/Manga/Seraph of the End/Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz"; expected.Add(filepath, new ParserInfo { - Series = "Seraph of the End - Vampire Reign", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "Seraph of the End - Vampire Reign", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "93", Filename = "Seraph of the End - Vampire Reign 093 (2020) (Digital) (LuCaZ).cbz", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); @@ -227,7 +227,7 @@ public class DefaultParserTests filepath = @"E:/Manga/The Beginning After the End/Chapter 001.cbz"; expected.Add(filepath, new ParserInfo { - Series = "The Beginning After the End", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "The Beginning After the End", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "1", Filename = "Chapter 001.cbz", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); @@ -236,7 +236,7 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Air Gear", Volumes = "1", Edition = "Omnibus", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", Format = MangaFormat.Archive, + Chapters = Parser.DefaultChapter, Filename = "Air Gear Omnibus v01 (2016) (Digital) (Shadowcat-Empire).cbz", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); @@ -244,7 +244,7 @@ public class DefaultParserTests expected.Add(filepath, new ParserInfo { Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "", - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, + Chapters = Parser.DefaultChapter, Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, FullFilePath = filepath, IsSpecial = false }); @@ -285,7 +285,7 @@ public class DefaultParserTests 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 = "", + Series = "Monster #8", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "8", Filename = "13.jpg", Format = MangaFormat.Image, FullFilePath = filepath, IsSpecial = false }; @@ -407,7 +407,7 @@ public class DefaultParserTests filepath = @"E:/Manga/Foo 50/Specials/Foo 50 SP01.cbz"; expected = new ParserInfo { - Series = "Foo 50", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, IsSpecial = true, + Series = "Foo 50", Volumes = Parser.SpecialVolume, IsSpecial = true, Chapters = Parser.DefaultChapter, Filename = "Foo 50 SP01.cbz", Format = MangaFormat.Archive, FullFilePath = filepath }; @@ -442,8 +442,8 @@ public class DefaultParserTests var filepath = @"E:/Comics/Teen Titans/Teen Titans v1 Annual 01 (1967) SP01.cbr"; expected.Add(filepath, new ParserInfo { - Series = "Teen Titans", Volumes = API.Services.Tasks.Scanner.Parser.Parser.SpecialVolume, - Chapters = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter, Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, + Series = "Teen Titans", Volumes = Parser.SpecialVolume, + Chapters = Parser.DefaultChapter, Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, FullFilePath = filepath }); @@ -451,7 +451,7 @@ public class DefaultParserTests filepath = @"E:/Comics/Comics/Babe/Babe Vol.1 #1-4/Babe 01.cbr"; expected.Add(filepath, new ParserInfo { - Series = "Babe", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "Babe", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "1", Filename = "Babe 01.cbr", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); @@ -467,7 +467,7 @@ public class DefaultParserTests filepath = @"E:/Comics/Comics/Batman - The Man Who Laughs #1 (2005)/Batman - The Man Who Laughs #1 (2005).cbr"; expected.Add(filepath, new ParserInfo { - Series = "Batman - The Man Who Laughs", Volumes = API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume, Edition = "", + Series = "Batman - The Man Who Laughs", Volumes = Parser.LooseLeafVolume, Edition = "", Chapters = "1", Filename = "Batman - The Man Who Laughs #1 (2005).cbr", Format = MangaFormat.Archive, FullFilePath = filepath, IsSpecial = false }); diff --git a/API.Tests/Parsing/MangaParsingTests.cs b/API.Tests/Parsing/MangaParsingTests.cs index 53f2bc4c9..a16d3ec4f 100644 --- a/API.Tests/Parsing/MangaParsingTests.cs +++ b/API.Tests/Parsing/MangaParsingTests.cs @@ -1,4 +1,5 @@ using API.Entities.Enums; +using API.Services.Tasks.Scanner.Parser; using Xunit; namespace API.Tests.Parsing; @@ -17,7 +18,7 @@ public class MangaParsingTests [InlineData("v001", "1")] [InlineData("Vol 1", "1")] [InlineData("vol_356-1", "356")] // Mangapy syntax - [InlineData("No Volume", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("No Volume", Parser.LooseLeafVolume)] [InlineData("U12 (Under 12) Vol. 0001 Ch. 0001 - Reiwa Scans (gb)", "1")] [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip", "1.1")] [InlineData("Tonikaku Cawaii [Volume 11].cbz", "11")] @@ -32,18 +33,18 @@ public class MangaParsingTests [InlineData("Dorohedoro v01 (2010) (Digital) (LostNerevarine-Empire).cbz", "1")] [InlineData("Dorohedoro v11 (2013) (Digital) (LostNerevarine-Empire).cbz", "11")] [InlineData("Yumekui_Merry_v01_c01[Bakayarou-Kuu].rar", "1")] - [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Yumekui-Merry_DKThias_Chapter11v2.zip", Parser.LooseLeafVolume)] [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", Parser.LooseLeafVolume)] [InlineData("VanDread-v01-c001[MD].zip", "1")] [InlineData("Ichiban_Ushiro_no_Daimaou_v04_ch27_[VISCANS].zip", "4")] [InlineData("Mob Psycho 100 v02 (2019) (Digital) (Shizu).cbz", "2")] [InlineData("Kodomo no Jikan vol. 1.cbz", "1")] [InlineData("Kodomo no Jikan vol. 10.cbz", "10")] - [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12 [Dametrans][v2]", Parser.LooseLeafVolume)] [InlineData("Vagabond_v03", "3")] [InlineData("Mujaki No Rakune Volume 10.cbz", "10")] - [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", Parser.LooseLeafVolume)] [InlineData("Volume 12 - Janken Boy is Coming!.cbz", "12")] [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "20")] [InlineData("Gantz.V26.cbz", "26")] @@ -52,7 +53,7 @@ public class MangaParsingTests [InlineData("NEEDLESS_Vol.4_-_Simeon_6_v2_[SugoiSugoi].rar", "4")] [InlineData("Okusama wa Shougakusei c003 (v01) [bokuwaNEET]", "1")] [InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")] - [InlineData("Noblesse - Episode 406 (52 Pages).7z", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Noblesse - Episode 406 (52 Pages).7z", Parser.LooseLeafVolume)] [InlineData("X-Men v1 #201 (September 2007).cbz", "1")] [InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")] [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03 Ch. 023.5 - Volume 3 Extras.cbz", "3")] @@ -64,7 +65,7 @@ public class MangaParsingTests [InlineData("スライム倒して300年、知らないうちにレベルMAXになってました 1-3巻", "1-3")] [InlineData("Dance in the Vampire Bund {Special Edition} v03.5 (2019) (Digital) (KG Manga)", "3.5")] [InlineData("Kebab Том 1 Глава 3", "1")] - [InlineData("Манга Глава 2", API.Services.Tasks.Scanner.Parser.Parser.LooseLeafVolume)] + [InlineData("Манга Глава 2", Parser.LooseLeafVolume)] [InlineData("Манга Тома 1-4", "1-4")] [InlineData("Манга Том 1-4", "1-4")] [InlineData("조선왕조실톡 106화", "106")] @@ -76,9 +77,19 @@ public class MangaParsingTests [InlineData("Accel World Volume 2", "2")] [InlineData("Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake", "30")] [InlineData("Zom 100 - Bucket List of the Dead v01", "1")] + // Tome Tests + [InlineData("Daredevil - t6 - 10 - (2019)", "6")] + [InlineData("Batgirl T2000 #57", "2000")] + [InlineData("Teen Titans t1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")] + [InlineData("Conquistador_Tome_2", "2")] + [InlineData("Max_l_explorateur-_Tome_0", "0")] + [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "3")] + [InlineData("Adventure Time (2012)/Adventure Time Ch 1 (2012)", Parser.LooseLeafVolume)] + [InlineData("Adventure Time TPB (2012)/Adventure Time v01 (2012).cbz", "1")] + [InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", Parser.LooseLeafVolume)] public void ParseVolumeTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseVolume(filename, LibraryType.Manga)); + Assert.Equal(expected, Parser.ParseVolume(filename, LibraryType.Manga)); } [Theory] @@ -206,21 +217,26 @@ public class MangaParsingTests [InlineData("[218565]-(C92) [BRIO (Puyocha)] Mika-nee no Tanryoku Shidou - Mika s Guide to Self-Confidence (THE IDOLM@STE", "")] [InlineData("Monster #8 Ch. 001", "Monster #8")] [InlineData("Zom 100 - Bucket List of the Dead v01", "Zom 100 - Bucket List of the Dead")] + [InlineData("Zom 100 - Tome 2", "Zom 100")] + [InlineData("Max_l_explorateur Tome 0", "Max l explorateur")] + [InlineData("Chevaliers d'Héliopolis T3 - Rubedo, l'oeuvre au rouge (Jodorowsky & Jérémy)", "Chevaliers d'Héliopolis")] + [InlineData("Bd Fr-Aldebaran-Antares-t6", "Bd Fr-Aldebaran-Antares")] + [InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", "Monster")] public void ParseSeriesTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseSeries(filename, LibraryType.Manga)); + Assert.Equal(expected, Parser.ParseSeries(filename, LibraryType.Manga)); } [Theory] [InlineData("Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb)", "1")] [InlineData("My Girlfriend Is Shobitch v01 - ch. 09 - pg. 008.png", "9")] [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98.zip", "90-98")] - [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] - [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("B_Gata_H_Kei_v01[SlowManga&OverloadScans]", Parser.DefaultChapter)] + [InlineData("BTOOOM! v01 (2013) (Digital) (Shadowcat-Empire)", Parser.DefaultChapter)] [InlineData("Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA]", "1-8")] - [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Dance in the Vampire Bund v16-17 (Digital) (NiceDragon)", Parser.DefaultChapter)] [InlineData("c001", "1")] - [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.12.zip", Parser.DefaultChapter)] [InlineData("Adding volume 1 with File: Ana Satsujin Vol. 1 Ch. 5 - Manga Box (gb).cbz", "5")] [InlineData("Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz", "18")] [InlineData("Cynthia The Mission - c000-006 (v06) [Desudesu&Brolen].zip", "0-6")] @@ -243,7 +259,7 @@ public class MangaParsingTests [InlineData("Itoshi no Karin - c001-006x1 (v01) [Renzokusei Scans]", "1-6")] [InlineData("APOSIMZ 040 (2020) (Digital) (danke-Empire).cbz", "40")] [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 12", "12")] - [InlineData("Vol 1", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Vol 1", Parser.DefaultChapter)] [InlineData("VanDread-v01-c001[MD].zip", "1")] [InlineData("Goblin Slayer Side Story - Year One 025.5", "25.5")] [InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 01", "1")] @@ -255,10 +271,10 @@ public class MangaParsingTests [InlineData("Fullmetal Alchemist chapters 101-108.cbz", "101-108")] [InlineData("Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz", "2")] [InlineData("To Love Ru v09 Uncensored (Ch.071-079).cbz", "71-79")] - [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter.rar", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter.rar", Parser.DefaultChapter)] [InlineData("Beelzebub_153b_RHS.zip", "153.5")] [InlineData("Beelzebub_150-153b_RHS.zip", "150-153.5")] - [InlineData("Transferred to another world magical swordsman v1.1", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Transferred to another world magical swordsman v1.1", Parser.DefaultChapter)] [InlineData("Kiss x Sis - Ch.15 - The Angst of a 15 Year Old Boy.cbz", "15")] [InlineData("Kiss x Sis - Ch.12 - 1 , 2 , 3P!.cbz", "12")] [InlineData("Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1", "1")] @@ -277,21 +293,21 @@ public class MangaParsingTests [InlineData("Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 1-10", "1-10")] [InlineData("Deku_&_Bakugo_-_Rising_v1_c1.1.cbz", "1.1")] [InlineData("Chapter 63 - The Promise Made for 520 Cenz.cbr", "63")] - [InlineData("Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Parser.DefaultChapter)] [InlineData("Kaiju No. 8 036 (2021) (Digital)", "36")] - [InlineData("Samurai Jack Vol. 01 - The threads of Time", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Samurai Jack Vol. 01 - The threads of Time", Parser.DefaultChapter)] [InlineData("【TFO汉化&Petit汉化】迷你偶像漫画第25话", "25")] [InlineData("자유록 13회#2", "13")] [InlineData("이세계에서 고아원을 열었지만, 어째서인지 아무도 독립하려 하지 않는다 38-1화 ", "38")] [InlineData("[ハレム]ナナとカオル ~高校生のSMごっこ~ 第10話", "10")] - [InlineData("Dance in the Vampire Bund {Special Edition} v03.5 (2019) (Digital) (KG Manga)", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Dance in the Vampire Bund {Special Edition} v03.5 (2019) (Digital) (KG Manga)", Parser.DefaultChapter)] [InlineData("Kebab Том 1 Глава 3", "3")] [InlineData("Манга Глава 2", "2")] [InlineData("Манга 2 Глава", "2")] [InlineData("Манга Том 1 2 Глава", "2")] [InlineData("Accel World Chapter 001 Volume 002", "1")] [InlineData("Bleach 001-003", "1-3")] - [InlineData("Accel World Volume 2", API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter)] + [InlineData("Accel World Volume 2", Parser.DefaultChapter)] [InlineData("Historys Strongest Disciple Kenichi_v11_c90-98", "90-98")] [InlineData("Historys Strongest Disciple Kenichi c01-c04", "1-4")] [InlineData("Adabana c00-02", "0-2")] @@ -299,9 +315,10 @@ public class MangaParsingTests [InlineData("Max Level Returner ตอนที่ 5", "5")] [InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")] [InlineData("Monster #8 Ch. 001", "1")] + [InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", "1")] public void ParseChaptersTest(string filename, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseChapter(filename, LibraryType.Manga)); + Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga)); } @@ -318,8 +335,9 @@ public class MangaParsingTests [InlineData("Love Hina Omnibus v05 (2015) (Digital-HD) (Asgard-Empire).cbz", "Omnibus")] public void ParseEditionTest(string input, string expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseEdition(input)); + Assert.Equal(expected, Parser.ParseEdition(input)); } + [Theory] [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", false)] [InlineData("Beelzebub_Omake_June_2012_RHS", false)] @@ -339,7 +357,7 @@ public class MangaParsingTests [InlineData("Hajime no Ippo - Artbook", false)] public void IsMangaSpecialTest(string input, bool expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.IsSpecial(input, LibraryType.Manga)); + Assert.Equal(expected, Parser.IsSpecial(input, LibraryType.Manga)); } [Theory] @@ -348,7 +366,7 @@ public class MangaParsingTests [InlineData("image.txt", MangaFormat.Unknown)] public void ParseFormatTest(string inputFile, MangaFormat expected) { - Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseFormat(inputFile)); + Assert.Equal(expected, Parser.ParseFormat(inputFile)); } diff --git a/API/API.csproj b/API/API.csproj index a7d1177dc..fa6b40bca 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -51,8 +51,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -66,20 +66,20 @@ - + - - - - - + + + + + - + @@ -91,15 +91,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 8f9b18317..c5a7b8fe4 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -16,6 +16,7 @@ using API.Extensions; using API.Helpers.Builders; using API.Services; using API.Services.Tasks.Scanner; +using API.Services.Tasks.Scanner.Parser; using API.SignalR; using AutoMapper; using EasyCaching.Core; @@ -83,6 +84,7 @@ public class LibraryController : BaseApiController .WithManageReadingLists(dto.ManageReadingLists) .WithAllowScrobbling(dto.AllowScrobbling) .WithAllowMetadataMatching(dto.AllowMetadataMatching) + .WithEnableMetadata(dto.EnableMetadata) .Build(); library.LibraryFileTypes = dto.FileGroupTypes @@ -173,6 +175,26 @@ public class LibraryController : BaseApiController return Ok(_directoryService.ListDirectory(path)); } + /// + /// For each root, checks if there are any supported files at root to warn the user during library creation about an invalid setup + /// + /// + [Authorize(Policy = "RequireAdminRole")] + [HttpPost("has-files-at-root")] + public ActionResult> AnyFilesAtRoot(CheckForFilesInFolderRootsDto dto) + { + var results = new Dictionary(); + foreach (var root in dto.Roots) + { + results.TryAdd(root, + _directoryService + .GetFilesWithCertainExtensions(root, Parser.SupportedExtensions, SearchOption.TopDirectoryOnly) + .Any()); + } + + return Ok(results); + } + /// /// Return a specific library /// diff --git a/API/Controllers/PersonController.cs b/API/Controllers/PersonController.cs index 7328ff954..487f19508 100644 --- a/API/Controllers/PersonController.cs +++ b/API/Controllers/PersonController.cs @@ -148,6 +148,18 @@ public class PersonController : BaseApiController return Ok(_mapper.Map(person)); } + /// + /// Validates if the ASIN (10/13) is valid + /// + /// + /// + [HttpGet("valid-asin")] + public ActionResult ValidateAsin(string asin) + { + return Ok(!string.IsNullOrEmpty(asin) && + (ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin))); + } + /// /// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita) /// diff --git a/API/DTOs/CheckForFilesInFolderRootsDto.cs b/API/DTOs/CheckForFilesInFolderRootsDto.cs new file mode 100644 index 000000000..42d4e2747 --- /dev/null +++ b/API/DTOs/CheckForFilesInFolderRootsDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace API.DTOs; + +public sealed record CheckForFilesInFolderRootsDto +{ + public ICollection Roots { get; init; } +} diff --git a/API/Helpers/PdfComicInfoExtractor.cs b/API/Helpers/PdfComicInfoExtractor.cs index ce74ae97d..861bb564f 100644 --- a/API/Helpers/PdfComicInfoExtractor.cs +++ b/API/Helpers/PdfComicInfoExtractor.cs @@ -130,7 +130,7 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor { try { - var extractor = new PdfMetadataExtractor(_logger, filePath); + using var extractor = new PdfMetadataExtractor(_logger, filePath); return GetComicInfoFromMetadata(extractor.GetMetadata(), filePath); } @@ -138,9 +138,12 @@ public class PdfComicInfoExtractor : IPdfComicInfoExtractor { _logger.LogWarning(ex, "[GetComicInfo] There was an exception parsing PDF metadata for {File}", filePath); _mediaErrorService.ReportMediaIssue(filePath, MediaErrorProducer.BookService, - "There was an exception parsing PDF metadata", ex); + ex.Message == "Encryption not supported" + ? "Encrypted PDFs are not supported" + : "There was an exception parsing PDF metadata", ex); } + return null; } } diff --git a/API/Helpers/PdfMetadataExtractor.cs b/API/Helpers/PdfMetadataExtractor.cs index 44327672b..dcf984247 100644 --- a/API/Helpers/PdfMetadataExtractor.cs +++ b/API/Helpers/PdfMetadataExtractor.cs @@ -1,23 +1,51 @@ -/** - * Contributed by https://github.com/microtherion - * - * All references to the "PDF Spec" (section numbers, etc) refer to the - * PDF 1.7 Specification a.k.a. PDF32000-1:2008 - * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf - */ - using System; using System.Collections.Generic; using System.IO.Compression; using System.Text; using System.Xml; using System.IO; -using Microsoft.Extensions.Logging; using API.Services; +using Microsoft.Extensions.Logging; namespace API.Helpers; #nullable enable +/** + * Contributed by https://github.com/microtherion + * + * All references to the "PDF Spec" (section numbers, etc.) refer to the + * PDF 1.7 Specification a.k.a. PDF32000-1:2008 + * https://opensource.adobe.com/dc-acrobat-sdk-docs/pdfstandards/PDF32000_2008.pdf + */ + +/** + * Reference for PDF Metadata Format + %PDF-1.4 ← Header + + Object 1 0 obj ← Objects containing content + << /Type /Catalog ... >> + endobj + + Object 2 0 obj + << /Type /Info ... >> + endobj + + ...more objects... + + xref ← Cross-reference table + 0 6 + 0000000000 65535 f + 0000000015 00000 n ← Object 1 is at byte offset 15 + 0000000109 00000 n ← Object 2 is at byte offset 109 + ... + + trailer ← Trailer dictionary + << /Size 6 /Root 1 0 R /Info 2 0 R >> + startxref + 1234 ← Byte offset where xref starts + %%EOF + */ + /// /// Parse PDF file and try to extract as much metadata as possible. /// Supports both text based XRef tables and compressed XRef streams (Deflate only). @@ -41,17 +69,17 @@ public class PdfMetadataExtractorException : Exception } } -public interface IPdfMetadataExtractor +public interface IPdfMetadataExtractor : IDisposable { - Dictionary GetMetadata(); + Dictionary GetMetadata(); } -class PdfStringBuilder +internal class PdfStringBuilder { private readonly StringBuilder _builder = new(); - private bool _secondByte = false; - private byte _prevByte = 0; - private bool _isUnicode = false; + private bool _secondByte; + private byte _prevByte; + private bool _isUnicode; // PDFDocEncoding defined in PDF Spec D.1 @@ -71,11 +99,11 @@ class PdfStringBuilder private void AppendPdfDocByte(byte b) { - if (b >= 0x18 && b < 0x20) + if (b is >= 0x18 and < 0x20) { _builder.Append(_pdfDocMappingLow[b - 0x18]); } - else if (b >= 0x80 && b < 0xA1) + else if (b is >= 0x80 and < 0xA1) { _builder.Append(_pdfDocMappingHigh[b - 0x80]); } @@ -95,28 +123,24 @@ class PdfStringBuilder // PDF Spec 7.9.2.1: Strings are either UTF-16BE or PDFDocEncoded if (_builder.Length == 0 && !_isUnicode) { - // Unicode strings are prefixed by a big endian BOM \uFEFF - if (_secondByte) + switch (_secondByte) { - if (b == 0xFF) - { + // Unicode strings are prefixed by a big endian BOM \uFEFF + case true when b == 0xFF: _isUnicode = true; _secondByte = false; - } - else - { + break; + case true: AppendPdfDocByte(_prevByte); AppendPdfDocByte(b); - } - } - else if (!_secondByte && b == 0xFE) - { - _secondByte = true; - _prevByte = b; - } - else - { - AppendPdfDocByte(b); + break; + case false when b == 0xFE: + _secondByte = true; + _prevByte = b; + break; + default: + AppendPdfDocByte(b); + break; } } else if (_isUnicode) @@ -138,7 +162,7 @@ class PdfStringBuilder } } - override public string ToString() + public override string ToString() { if (_builder.Length == 0 && _secondByte) { @@ -153,8 +177,8 @@ internal class PdfLexer(Stream stream) { private const int BufferSize = 1024; private readonly byte[] _buffer = new byte[BufferSize]; - private int _pos = 0; - private int _valid = 0; + private int _pos; + private int _valid; public enum TokenType { @@ -353,10 +377,8 @@ internal class PdfLexer(Stream stream) { return (long)token.Value; } - else - { - throw new PdfMetadataExtractorException("Expected integer after startxref keyword"); - } + + throw new PdfMetadataExtractorException("Expected integer after startxref keyword"); } continue; @@ -367,10 +389,18 @@ internal class PdfLexer(Stream stream) } } - public bool NextXRefEntry(ref long obj, ref int generation) + /// + /// + /// + /// + /// 0000000015 00000 n ← offset=15, generation=0, in-use + /// 0000000109 00000 n ← offset=109, generation=0, in-use + /// 0000000000 65535 f ← offset=0, generation=65535, free + /// + /// Cross-reference table entry as per PDF Spec 7.5.4 + /// + public bool NextXRefEntry(out long offset, out int generation) { - // Cross-reference table entry as per PDF Spec 7.5.4 - WantLookahead(20); if (_valid - _pos < 20) @@ -378,14 +408,11 @@ internal class PdfLexer(Stream stream) throw new PdfMetadataExtractorException("End of stream"); } - var inUse = true; + // Parse the 20-byte XRef entry: "nnnnnnnnnn ggggg n/f \r\n" + offset = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10).Trim()); + generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5).Trim()); - if (obj == 0) - { - obj = Convert.ToInt64(Encoding.ASCII.GetString(_buffer, _pos, 10)); - generation = Convert.ToInt32(Encoding.ASCII.GetString(_buffer, _pos + 11, 5)); - inUse = _buffer[_pos + 17] == 'n'; - } + var inUse = _buffer[_pos + 17] == 'n'; _pos += 20; @@ -503,7 +530,7 @@ internal class PdfLexer(Stream stream) { StringBuilder sb = new(); var hasDot = LastByte() == '.'; - var followedBySpace = false; + bool followedBySpace; sb.Append((char)LastByte()); @@ -647,7 +674,8 @@ internal class PdfLexer(Stream stream) case '(': parenLevel++; - goto default; + sb.AppendByte(b); + break; case ')': if (--parenLevel == 0) @@ -655,7 +683,8 @@ internal class PdfLexer(Stream stream) return new Token(TokenType.String, sb.ToString()); } - goto default; + sb.AppendByte(b); + break; case '\\': b = NextByte(); @@ -688,7 +717,6 @@ internal class PdfLexer(Stream stream) break; case >= '0' and <= '7': - var b1 = b; var b2 = NextByte(); var b3 = NextByte(); @@ -697,7 +725,7 @@ internal class PdfLexer(Stream stream) throw new PdfMetadataExtractorException("Invalid octal escape, got {b1}{b2}{b3}"); } - sb.AppendByte((byte)((b1 - '0') << 6 | (b2 - '0') << 3 | (b3 - '0'))); + sb.AppendByte((byte)((b - '0') << 6 | (b2 - '0') << 3 | (b3 - '0'))); break; } @@ -763,26 +791,15 @@ internal class PdfLexer(Stream stream) } } - switch (sb.ToString()) + return sb.ToString() switch { - case "true": - return new Token(TokenType.Bool, true); - - case "false": - return new Token(TokenType.Bool, false); - - case "stream": - return new Token(TokenType.StreamStart, true); - - case "endstream": - return new Token(TokenType.StreamEnd, true); - - case "endobj": - return new Token(TokenType.ObjectEnd, true); - - default: - return new Token(TokenType.Keyword, sb.ToString()); - } + "true" => new Token(TokenType.Bool, true), + "false" => new Token(TokenType.Bool, false), + "stream" => new Token(TokenType.StreamStart, true), + "endstream" => new Token(TokenType.StreamEnd, true), + "endobj" => new Token(TokenType.ObjectEnd, true), + _ => new Token(TokenType.Keyword, sb.ToString()) + }; } } @@ -791,9 +808,10 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor private readonly ILogger _logger; private readonly PdfLexer _lexer; private readonly FileStream _stream; - private long[] _objectOffsets = new long[0]; + private readonly Dictionary _objectOffsets = []; private readonly Dictionary _metadata = []; private readonly Stack _metadataRef = new(); + private bool _disposed; private struct MetadataRef(long root, long info) { @@ -801,7 +819,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor public long Info = info; } - private struct XRefSection(long first, long count) + private readonly struct XRefSection(long first, long count) { public readonly long First = first; public readonly long Count = count; @@ -822,7 +840,9 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor return _metadata; } +#pragma warning disable S1144 private void LogMetadata(string filename) +#pragma warning restore S1144 { _logger.LogTrace("Metadata for {Path}:", filename); @@ -854,14 +874,11 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor if (!_lexer.TestByte((byte)'x')) { // Cross-reference stream (PDF Spec 7.5.8) - ReadXRefStream(); - return; } // Cross-reference table (PDF Spec 7.5.4) - var token = _lexer.NextToken(); if (token.Type != PdfLexer.TokenType.Keyword || (string)token.Value != "xref") @@ -885,23 +902,17 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor var numObj = (long)token.Value; - if (_objectOffsets.Length < startObj + numObj) - { - Array.Resize(ref _objectOffsets, (int)(startObj + numObj)); - } - _lexer.ExpectNewline(); - var generation = 0; - for (var obj = startObj; obj < startObj + numObj; ++obj) { - var inUse = _lexer.NextXRefEntry(ref _objectOffsets[obj], ref generation); + var inUse = _lexer.NextXRefEntry(out var offset, out var generation); - if (!inUse) + if (inUse && offset > 0) { - _objectOffsets[obj] = 0; + _objectOffsets[obj] = offset ; } + // Free objects (inUse == false) are not stored in the dictionary } } else if (token.Type == PdfLexer.TokenType.Keyword && (string)token.Value == "trailer") @@ -1105,11 +1116,6 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor { var section = sections.Dequeue(); - if (_objectOffsets.Length < size) - { - Array.Resize(ref _objectOffsets, (int)size); - } - for (var i = section.First; i < section.First + section.Count; ++i) { long type = 0; @@ -1136,9 +1142,9 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor generation = (generation << 8) | (ushort)stream.ReadByte(); } - if (type == 1 && _objectOffsets[i] == 0) + if (type == 1) { - _objectOffsets[i] = offset; + _objectOffsets.TryAdd(i, offset); } } } @@ -1253,7 +1259,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor { var meta = _metadataRef.Pop(); - //_logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.root, meta.info); + _logger.LogTrace("DocumentCatalog for {Path}: {Root}, Info: {Info}", filename, meta.Root, meta.Info); ReadMetadataFromInfo(meta.Info); ReadMetadataFromXml(MetadataObjInObjectCatalog(meta.Root)); @@ -1265,7 +1271,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor // Document information dictionary (PDF Spec 14.3.3) // We treat this as less authoritative than the Metadata stream. - if (infoObj < 1 || infoObj >= _objectOffsets.Length || _objectOffsets[infoObj] == 0) + if (!HasObject(infoObj)) { return; } @@ -1338,7 +1344,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor { // Look for /Metadata entry in document catalog (PDF Spec 7.7.2) - if (rootObj < 1 || rootObj >= _objectOffsets.Length || _objectOffsets[rootObj] == 0) + if (!HasObject(rootObj)) { return -1; } @@ -1416,7 +1422,7 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor private void ReadMetadataFromXml(long meta) { - if (meta < 1 || meta >= _objectOffsets.Length || _objectOffsets[meta] == 0) return; + if (!HasObject(meta)) return; _stream.Seek(_objectOffsets[meta], SeekOrigin.Begin); _lexer.ResetBuffer(); @@ -1634,4 +1640,28 @@ internal class PdfMetadataExtractor : IPdfMetadataExtractor SkipValue(); } } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed || !disposing) return; + + _stream.Dispose(); + _disposed = true; + } + + private bool HasObject(long objNum) + { + return _objectOffsets.ContainsKey(objNum) && _objectOffsets[objNum] > 0; + } + + private long GetObjectOffset(long objNum) + { + return _objectOffsets.TryGetValue(objNum, out var offset) ? offset : 0; + } } diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 99fdd1400..5cd4b9646 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -679,10 +679,8 @@ public class BookService : IBookService { return _pdfComicInfoExtractor.GetComicInfo(filePath); } - else - { - return GetEpubComicInfo(filePath); - } + + return GetEpubComicInfo(filePath); } private static void ExtractSortTitle(EpubMetadataMeta metadataItem, EpubBookRef epubBook, ComicInfo info) diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 544efa4ce..b4be43ada 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -30,8 +30,9 @@ public interface IImageService /// /// Convert and save as encoding format /// Width of thumbnail - /// File name with extension of the file. This will always write to - string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320); + /// If null, will write to + /// File name with extension of the file. + string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = 320, string? targetDirectory = null); /// /// Writes out a thumbnail by stream input /// @@ -576,14 +577,16 @@ public class ImageService : IImageService /// - public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth) + public string CreateThumbnailFromBase64(string encodedImage, string fileName, EncodeFormat encodeFormat, int thumbnailWidth = ThumbnailWidth, string? targetDirectory = null) { // TODO: This code has no concept of cropping nor Thumbnail Size try { + targetDirectory ??= _directoryService.CoverImageDirectory; using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), thumbnailWidth); fileName += encodeFormat.GetExtension(); - thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(_directoryService.CoverImageDirectory, fileName)); + thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(targetDirectory, fileName)); + return fileName; } catch (Exception e) diff --git a/API/Services/Tasks/Metadata/CoverDbService.cs b/API/Services/Tasks/Metadata/CoverDbService.cs index 015613965..080ef2f8b 100644 --- a/API/Services/Tasks/Metadata/CoverDbService.cs +++ b/API/Services/Tasks/Metadata/CoverDbService.cs @@ -478,6 +478,8 @@ public class CoverDbService : ICoverDbService var format = ImageService.GetPersonFormat(person.Id); var finalFileName = format + ".webp"; var tempFileName = format + "_new"; + + // This is writing the image to CoverDirectory var tempFilePath = await CreateThumbnail(url, tempFileName, fromBase64, tempDir); if (!string.IsNullOrEmpty(tempFilePath)) @@ -719,7 +721,7 @@ public class CoverDbService : ICoverDbService /// /// Filename without extension /// - /// Not useable with fromBase64. Allows a different directory to be written to + /// Allows a different directory to be written to /// private async Task CreateThumbnail(string url, string filenameWithoutExtension, bool fromBase64 = true, string? targetDirectory = null) { @@ -732,7 +734,7 @@ public class CoverDbService : ICoverDbService if (fromBase64) { return _imageService.CreateThumbnailFromBase64(url, - filenameWithoutExtension, encodeFormat, coverImageSize.GetDimensions().Width); + filenameWithoutExtension, encodeFormat, coverImageSize.GetDimensions().Width, targetDirectory); } return await DownloadImageFromUrl(filenameWithoutExtension, encodeFormat, url, targetDirectory); diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index c0b130f91..673fa4d95 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -118,83 +118,6 @@ public static partial class Parser MatchOptions, RegexTimeout); - private static readonly Regex[] MangaVolumeRegex = - [ - // Thai Volume: เล่ม n -> Volume n - new Regex( - @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", - MatchOptions, RegexTimeout), - // Dance in the Vampire Bund v16-17 - new Regex( - @"(?.*)(\b|_)v(?\d+-?\d+)( |_)", - MatchOptions, RegexTimeout), - // Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake - new Regex( - @"^(?.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+(Vol(ume)?\.?(\s|_)?)(?\d+(\.\d+)?)(.+?|$)", - MatchOptions, RegexTimeout), - // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 - new Regex( - @"(?.*)(\b|_)(?!\[)v(?" + NumberRange + @")(?!\])", - MatchOptions, RegexTimeout), - // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 - new Regex( - @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", - MatchOptions, RegexTimeout), - // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) - new Regex( - @"(vol\.? ?)(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Tonikaku Cawaii [Volume 11].cbz - new Regex( - @"(volume )(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - // Tower Of God S01 014 (CBT) (digital).cbz - new Regex( - @"(?.*)(\b|_|)(S(?\d+))", - MatchOptions, RegexTimeout), - // vol_001-1.cbz for MangaPy default naming convention - new Regex( - @"(vol_)(?\d+(\.\d)?)", - MatchOptions, RegexTimeout), - - // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 - new Regex( - @"第(?\d+)(卷|册)", - MatchOptions, RegexTimeout), - // Chinese Volume: 卷n -> Volume n, 册n -> Volume n - new Regex( - @"(卷|册)(?\d+)", - MatchOptions, RegexTimeout), - // Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) - new Regex( - @"제?(?\d+(\.\d+)?)(권|화|장)", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, - new Regex( - @"시즌(?\d+\-?\d+)", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, n시즌 -> season n - new Regex( - @"(?\d+(\-|~)?\d+?)시즌", - MatchOptions, RegexTimeout), - // Korean Season: 시즌n -> Season n, n시즌 -> season n - new Regex( - @"시즌(?\d+(\-|~)?\d+?)", - MatchOptions, RegexTimeout), - // Japanese Volume: n巻 -> Volume n - new Regex( - @"(?\d+(?:(\-)\d+)?)巻", - MatchOptions, RegexTimeout), - // Russian Volume: Том n -> Volume n, Тома n -> Volume - new Regex( - @"Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", - MatchOptions, RegexTimeout), - // Russian Volume: n Том -> Volume n - new Regex( - @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", - MatchOptions, RegexTimeout) - ]; - private static readonly Regex[] MangaSeriesRegex = [ // Thai Volume: เล่ม n -> Volume n @@ -239,7 +162,7 @@ public static partial class Parser RegexTimeout), // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] new Regex( - @"(?.+?)( - )(?:v|vo|c|chapters)\d", + @"(?.+?)( - )(?:v|vo|c|chapters|tome|t|ch)\d", MatchOptions, RegexTimeout), // Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip new Regex( @@ -251,14 +174,18 @@ public static partial class Parser MatchOptions, RegexTimeout), // [dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz new Regex( - @"(?.+?):? (\b|_|-)(vol)\.?(\s|-|_)?\d+", + @"(?.+?):? (\b|_|-)(vol|tome)\.?(\s|-|_)?\d+", MatchOptions, RegexTimeout), // [xPearse] Kyochuu Rettou Chapter 001 Volume 1 [English] [Manga] [Volume Scans] new Regex( @"(?.+?):?(\s|\b|_|-)Chapter(\s|\b|_|-)\d+(\s|\b|_|-)(vol)(ume)", MatchOptions, RegexTimeout), - + // Kyochuu Rettou T3, Kyochuu Rettou - Tome 3 + new Regex( + @"(?.+?):? (\b|_|-)(t\d+|tome(\b|_)\d+)", + MatchOptions, + RegexTimeout), // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] new Regex( @"(?.+?):? (\b|_|-)(vol)(ume)", @@ -270,7 +197,7 @@ public static partial class Parser MatchOptions, RegexTimeout), //Tonikaku Cawaii [Volume 11], Darling in the FranXX - Volume 01.cbz new Regex( - @"(?.*)(?: _|-|\[|\()\s?vol(ume)?", + @"(?.*)(?: _|-|\[|\()\s?(vol(ume)?|tome|t\d+)", MatchOptions, RegexTimeout), // Momo The Blood Taker - Chapter 027 Violent Emotion.cbz, Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz new Regex( @@ -465,6 +392,83 @@ public static partial class Parser MatchOptions, RegexTimeout) ]; + private static readonly Regex[] MangaVolumeRegex = + [ + // Thai Volume: เล่ม n -> Volume n + new Regex( + @"(เล่ม|เล่มที่)(\s)?(\.?)(\s|_)?(?\d+(\-\d+)?(\.\d+)?)", + MatchOptions, RegexTimeout), + // Dance in the Vampire Bund v16-17, Dance in the Vampire Bund Tome 1 + new Regex( + @"(?.*)(\b|_)(v|tome(\s|_)?|t)(?\d+-?\d+)(\s|_)", + MatchOptions, RegexTimeout), + // Nagasarete Airantou - Vol. 30 Ch. 187.5 - Vol.31 Omake + new Regex( + @"^(?.+?)(\s*Chapter\s*\d+)?(\s|_|\-\s)+((Vol(ume)?|tome)\.?(\s|_)?)(?\d+(\.\d+)?)(.+?|$)", + MatchOptions, RegexTimeout), + // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 + new Regex( + @"(?.*)(\b|_)(?!\[)v(?" + NumberRange + @")(?!\])", + MatchOptions, RegexTimeout), + // Kodomo no Jikan vol. 10, [dmntsf.net] One Piece - Digital Colored Comics Vol. 20.5-21.5 Ch. 177 + new Regex( + @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", + MatchOptions, RegexTimeout), + // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) + new Regex( + @"(vol\.? ?)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Tonikaku Cawaii [Volume 11].cbz + new Regex( + @"((volume|tome)\s)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + // Tower Of God S01 014 (CBT) (digital).cbz, Tower Of God T01 014 (CBT) (digital).cbz, + new Regex( + @"(?.*)(\b|_)((S|T)(?\d+))", + MatchOptions, RegexTimeout), + // vol_001-1.cbz for MangaPy default naming convention + new Regex( + @"(vol_)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), + + // Chinese Volume: 第n卷 -> Volume n, 第n册 -> Volume n, 幽游白书完全版 第03卷 天下 or 阿衰online 第1册 + new Regex( + @"第(?\d+)(卷|册)", + MatchOptions, RegexTimeout), + // Chinese Volume: 卷n -> Volume n, 册n -> Volume n + new Regex( + @"(卷|册)(?\d+)", + MatchOptions, RegexTimeout), + // Korean Volume: 제n화|회|장 -> Volume n, n화|권|장 -> Volume n, 63권#200.zip -> Volume 63 (no chapter, #200 is just files inside) + new Regex( + @"제?(?\d+(\.\d+)?)(권|화|장)", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, + new Regex( + @"시즌(?\d+\-?\d+)", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"(?\d+(\-|~)?\d+?)시즌", + MatchOptions, RegexTimeout), + // Korean Season: 시즌n -> Season n, n시즌 -> season n + new Regex( + @"시즌(?\d+(\-|~)?\d+?)", + MatchOptions, RegexTimeout), + // Japanese Volume: n巻 -> Volume n + new Regex( + @"(?\d+(?:(\-)\d+)?)巻", + MatchOptions, RegexTimeout), + // Russian Volume: Том n -> Volume n, Тома n -> Volume + new Regex( + @"Том(а?)(\.?)(\s|_)?(?\d+(?:(\-)\d+)?)", + MatchOptions, RegexTimeout), + // Russian Volume: n Том -> Volume n + new Regex( + @"(\s|_)?(?\d+(?:(\-)\d+)?)(\s|_)Том(а?)", + MatchOptions, RegexTimeout) + ]; + private static readonly Regex[] ComicVolumeRegex = [ // Thai Volume: เล่ม n -> Volume n diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index dfc6bafd9..ec363f083 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -12,9 +12,9 @@ - - - + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 8c851dd80..4c945cf9a 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -1,11 +1,11 @@ -import { HttpClient } from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {DestroyRef, Injectable} from '@angular/core'; -import { of } from 'rxjs'; +import {of} from 'rxjs'; import {filter, map, tap} from 'rxjs/operators'; -import { environment } from 'src/environments/environment'; -import { JumpKey } from '../_models/jumpbar/jump-key'; -import { Library, LibraryType } from '../_models/library/library'; -import { DirectoryDto } from '../_models/system/directory-dto'; +import {environment} from 'src/environments/environment'; +import {JumpKey} from '../_models/jumpbar/jump-key'; +import {Library, LibraryType} from '../_models/library/library'; +import {DirectoryDto} from '../_models/system/directory-dto'; import {EVENTS, MessageHubService} from "./message-hub.service"; import {takeUntilDestroyed} from "@angular/core/rxjs-interop"; @@ -73,6 +73,10 @@ export class LibraryService { return this.httpClient.get(this.baseUrl + 'library/list' + query); } + hasFilesAtRoot(roots: Array) { + return this.httpClient.post<{[key: string]: boolean}>(this.baseUrl + 'library/has-files-at-root', {roots}); + } + getJumpBar(libraryId: number) { return this.httpClient.get(this.baseUrl + 'library/jump-bar?libraryId=' + libraryId); } diff --git a/UI/Web/src/app/_services/person.service.ts b/UI/Web/src/app/_services/person.service.ts index fc9148135..bfb312d8a 100644 --- a/UI/Web/src/app/_services/person.service.ts +++ b/UI/Web/src/app/_services/person.service.ts @@ -78,6 +78,12 @@ export class PersonService { ); } + isValidAsin(asin: string) { + return this.httpClient.get(this.baseUrl + `person/valid-asin?asin=${asin}`, TextResonse).pipe( + map(valid => valid + '' === 'true') + ); + } + mergePerson(destId: number, srcId: number) { return this.httpClient.post(this.baseUrl + 'person/merge', {destId, srcId}); } diff --git a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts index fa2a37ae9..41451e872 100644 --- a/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts +++ b/UI/Web/src/app/cards/cover-image-chooser/cover-image-chooser.component.ts @@ -10,13 +10,13 @@ import { Output } from '@angular/core'; import {FormBuilder, FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; -import {NgxFileDropEntry, FileSystemFileEntry, NgxFileDropModule} from 'ngx-file-drop'; -import { fromEvent } from 'rxjs'; -import { takeWhile } from 'rxjs/operators'; -import { ToastrService } from 'ngx-toastr'; -import { ImageService } from 'src/app/_services/image.service'; -import { KEY_CODES } from 'src/app/shared/_services/utility.service'; -import { UploadService } from 'src/app/_services/upload.service'; +import {FileSystemFileEntry, NgxFileDropEntry, NgxFileDropModule} from 'ngx-file-drop'; +import {fromEvent} from 'rxjs'; +import {takeWhile} from 'rxjs/operators'; +import {ToastrService} from 'ngx-toastr'; +import {ImageService} from 'src/app/_services/image.service'; +import {KEY_CODES} from 'src/app/shared/_services/utility.service'; +import {UploadService} from 'src/app/_services/upload.service'; import {DOCUMENT, NgClass} from '@angular/common'; import {ImageComponent} from "../../shared/image/image.component"; import {translate, TranslocoModule} from "@jsverse/transloco"; @@ -233,7 +233,8 @@ export class CoverImageChooserComponent implements OnInit { this.imageSelected.emit(this.selectedIndex); // Auto select newly uploaded image this.selectedBase64Url.emit(e.target.result); setTimeout(() => { - (this.document.querySelector('div.image-card[aria-label="Image ' + this.selectedIndex + '"]') as HTMLElement).focus(); + // Add 1 since we are adding a new image + (this.document.querySelector('div.clickable[aria-label="Image ' + (this.selectedIndex + 1) + '"]') as HTMLElement).focus(); }) this.cdRef.markForCheck(); } diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html index f6ae1c6ae..84a5821e3 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.html @@ -73,7 +73,13 @@ - + @if (formControl.errors; as errors) { +
+ @if (errors.invalidAsin) { +
{{t('invalid-asin')}}
+ } +
+ }
diff --git a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts index 74a20e951..e014411db 100644 --- a/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts +++ b/UI/Web/src/app/person-detail/_modal/edit-person-modal/edit-person-modal.component.ts @@ -73,7 +73,7 @@ export class EditPersonModalComponent implements OnInit { editForm: FormGroup = new FormGroup({ name: new FormControl('', [Validators.required]), description: new FormControl('', []), - asin: new FormControl('', []), + asin: new FormControl('', [], [this.asinValidator()]), aniListId: new FormControl('', []), malId: new FormControl('', []), hardcoverId: new FormControl('', []), @@ -194,4 +194,21 @@ export class EditPersonModalComponent implements OnInit { } } + asinValidator(): AsyncValidatorFn { + return (control: AbstractControl) => { + const asin = control.value; + if (!asin || asin.trim().length === 0) { + return of(null); + } + + return this.personService.isValidAsin(asin).pipe(map(valid => { + if (valid) { + return null; + } + + return { 'invalidAsin': {'asin': asin} } as ValidationErrors; + })); + } + } + } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html index e8a3bafeb..8c2d00c02 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.html @@ -69,6 +69,11 @@
  • {{t(TabID.Folder)}} + + @if (filesAtRoot()) { +

    {{t('files-at-root-warning')}}

    + } +

    {{t('folder-description')}}

      @for(folder of selectedFolders; track folder) { @@ -77,7 +82,6 @@ } -