diff --git a/API.Benchmark/API.Benchmark.csproj b/API.Benchmark/API.Benchmark.csproj index 529f3cc93..d63d24ddc 100644 --- a/API.Benchmark/API.Benchmark.csproj +++ b/API.Benchmark/API.Benchmark.csproj @@ -15,4 +15,10 @@ + + + Always + + + diff --git a/API.Benchmark/Data/SeriesNamesForNormalization.txt b/API.Benchmark/Data/SeriesNamesForNormalization.txt new file mode 100644 index 000000000..99ae529fd --- /dev/null +++ b/API.Benchmark/Data/SeriesNamesForNormalization.txt @@ -0,0 +1,573 @@ +Liar-Game +Your Lie in April +Love Hina +Love Hina +A Chronicle of the Last Pagans +Otherworldly Munchkin - Let's Speedrun the Dungeon with Only 1 HP! +Love Hina +Rokka - Braves of the Six Flowers +Real Account +Bakekano +Yancha Gal no Anjou-san +Moshi Fanren +The Devil Is a Part-Timer! +My Home Hero +Itoshi no Karin +Claymore +Dolls Fall +Dragons Rioting +Tokyo Ghoul - re +Hajime no Ippo +Mahoromatic +DEATHTOPIA +Negima! Neo - Magister Negi Magi +Ichinensei ni Nacchattara +How NOT to Summon a Demon Lord +U12 +"Don't Toy With Me, Miss Nagatoro" +Karakai Jouzu no Takagi-san +UQ Holder! +"Ore no Nounai Sentakushi ga, Gakuen Rabukome o Zenryoku de Jama Shite Iru" +Do Chokkyuu Kareshi x Kanojo +Ana Satsujin +Deus Ex Machina +Hidan no Aria +Bokura wa Minna Kawaisou +Epigraph of the Closed Curve +Ibitsu +Rave Master +Lunar Legend Tsukihime +Starving Anonymous +High-Rise Invasion +Fuuka +Dai Dark +Zero no Tsukaima Chevalier +Cells at Work! CODE BLACK +004 Cut Hero +Renjoh Desperado +Himegoto - Juukyuusai No Seifuku +Shark Skin Man and Peach Hip Girl +Tokyo Revengers +Fire Punch +Boarding School Juliet +Mushihime +Sankarea - Undying Love +Hanako and the Terror of Allegory +Mad Chimera World +Kono Subarashii Sekai ni Bakuen wo! +21st Century Boys +Kono Subarashii Sekai ni Shukufuku wo! Megumin Anthology +Konosuba +Iinari +Shimoneta - Manmaru Hen +Ichiban Ushiro No Daimaou +Yamada-kun and the Seven Witches +Busou Shoujo Machiavellism +Negative Happy Chainsaw Edge +Stravaganza - Isai No Hime +Seraph of the End - Vampire Reign 095 +Seraph of the End - Vampire Reign 098 +Kokkoku - Moment by Moment +Magico +Samurai Harem - Asu no Yoichi +Change123 +Shomin Sample +Eureka SeveN +Kekkaishi +Goblin Slayer Side Story - Year One +Yomeiro Choice +Okusama wa Shougakusei +Monster No Goshujin-Sama +Ase To Sekken +How Do We Relationship +Hantsu x Torasshu +Magical Girl Apocalypse +I Am a Hero +Air Gear +Dolly Kill Kill +Blue Exorcist +Kingdom of Z +The Fable +Mairimashita! Iruma-kun +Spy x Family +Goblin Slayer - Brand New Day +Yesterday wo Utatte +Mujaki No Rakuen +Summer Time Rendering +Eureka Seven Gravity Boys and Lifting Girl +06 +Domestic Girlfriend +Imperfect Girl +Chrno Crusade +Higurashi no Naku Koro ni Kai - Tsumihoroboshihen +Nande koko ni sensei ga! +Fukukaichou Ganbaru. +Fraction +Kono Subarashii Sekai ni Shukufuku wo! Megumin Anthology Aka +Mouryou no Yurikago +Ral Ω Grad +Shomin Sample I Was Abducted by an Elite All-Girls School as a Sample Commoner +City of Love Prison +Tsugumomo +Highschool of the Dead - Edition +Cynthia The Mission +Amano Megumi wa Suki Darake! +Aria The Scarlet Ammo +Noblesse +Outlanders +Bleach +Kimi ni Todoke +Corpse Party - Another Child +The Heroic Legend of Arslan +Fujiyama-San Wa Shishunki +Let's Go Play +Astra Lost in Space +Mirai Nikki +Doubt +Again!! +Gesellschaft Blume +Momo The Blood Taker +World's End Harem - Fantasia +Tengoku Daimakyou +Amaenaideyo MS +Cage of Eden +Arifureta - From Commonplace to World's Strongest +"The 100 Girlfriends Who Really, Really, Really, Really, Really Love You" +Frogman +Chaika - The Coffin Princess +Pandora Hearts +I'm Not a Lolicon! +Criminale! +Drifting Net Cafe +Kono Subarashii Sekai ni Nichijou wo! +Tomodachi Game +Accel World +Sun-Ken Rock +Parallel Paradise +Otherworldly Munchkin - Let's Speedrun the Dungeon with Only 1 HP! +Hentai Ouji to Warawanai Neko. Nya! +Gokukoku no Brynhildr +Rosario+Vampire Season 2 +Higurashi no Naku Koro ni - Tatarigoroshihen +BEASTARS +Grenadier +The Duke of Death and His Black Maid +Helck +Ijousha no Ai +Beelzebub +Infection +"Ota Tomo ga Kareshi ni Nattara, Saikou, Kamo Shirenai" +Battle Vixens +Kimi ha midara na Boku no Joou +Immortal Hounds +Battle Angel Alita +My Monster Secret +Blood Rain +Kakegurui - Compulsive Gambler +Combatants Will Be Dispatched! +Tenjo Tenge - Digital Colored Comics +Dorohedoro +Tower Of God +Toradora! +Spice and Wolf +Loose Relation Between Wizard and Apprentice +Kaguya-sama - Love Is War - Digital Colored Comics +RaW Hero +Aiki +Jagaaaaaan +Gleipnir +Darwin's Game +I'm Standing on a Million Lives +Battle Club +School Rumble Z +Wotakoi - Love Is Hard for Otaku +Majimoji Rurumo +Suisei no Gargantia +Madan No Ou To Vanadis +Full Metal Panic - Sigma +Konosuba - An Explosion on This Wonderful World! +Seraph of the End - Vampire Reign 096 +Higurashi no Naku Koro ni - Onikakushihen +Corpse Party Cemetery 0 - Kaibyaku No Ars Moriendi +World's End Harem +Jack Frost +The Men Who Created The Prison School Anime +My Hero Academia +Elfen Lied +Berserk +Witchcraft Works +Chobits 20th Anniversary Edition +Mx0 +Youkai Kyoushitsu +Horimiya +Mieruko-chan +Drifters +Suzuka +The Iceblade Magician Rules Over the World +Kaiju No. 8 +Yu-Gi-Oh! +"A Story About Treating a Female Knight, Who Has Never Been Treated as a Woman, as a Woman" +Mahoutsukai to Deshi no Futekisetsu na Kankei +Battle Royale +Mato Seihei no Slave +One-Punch Man +Boku No Kokoro No Yabai Yatsu +Doku Mushi +Kuzu no Honkai +Hoshihimemura No Naishobanashi +Knights of Sidonia +Amaenaideyo +Kono Subarashii Sekai ni Shukufuku wo! Spin-off Kono Kamen no Akuma ni Soudan wo! +Killing Bites +Fly Me to the Moon +Tenjo Tenge +D-Princess +7thGARDEN +Sumomomo Momomo +Accel World Dural - Magisa Garden +History's Strongest Disciple Kenichi +Future Diary - Mosaic +DEAD Tube +Kaworu Watashiya - Kodomo no Jikan +Undead Unluck +Black Bullet +Fureru To Kikoeru +Konchuki +Akuma no Riddle - Riddle Story of Devil +Great Teacher Onizuka +Scumbag Loser +Jisatsutou +Boku wa Mari no Naka +Cherry x Cherry +Seraph of the End - Vampire Reign 093 +Yumekui Merry - 4-Koma Anthology +Love and Lies +Nisekoi - False Love +Another +My Balls +Akame ga KILL! +Corpse Princess +Needless 0 +My Charms Are Wasted On Kuroiwa Medaka +Made in Abyss +Hanako to Guuwa no Tera +Yumekui Merry +Miman Renai +Sundome +Gantz +Accomplishments of the Duke's Daughter +Grimgar of Fantasy and Ash +Dansei Kyoufushou Datta Watashi Ga Av Jouyu Ni Naru Made No Hanashi +Hour of the Zombie +NOiSE +Onani Master Kurosawa +Sekirei +Full Metal Panic +Zero no Tsukaima +Solo Leveling +B Gata H Kei +Shurabara! +DEATH NOTE +Terra Formars +Goblin Slayer +March Story +Nozoki Ana +Youkai Shoujo - Monsuga +Maji de Watashi ni Koi Shinasai!! +"Ore no Nounai Sentakushi ga, Gakuen Rabukome o Zenryoku de Jama Shite Iru H" +Destruction Princess +Mob Psycho 100 +Negima! +Zero - The Illust collection of The Familiar of Zero +20th Century Boys +Girls of the Wild's +Bleach - Digital Colored Comics +Taboo Tattoo +Let's Buy The Land And Cultivate In Different World +Oroka na Tenshi wa Akuma to Odoru +Future Diary +Negima! Party Book! +Buso Renkin +Offal Island +Mysterious Girlfriend X +Getsurin ni Kiri Saku +Magi +Uzaki-chan Wants to Hang Out! +A Town Where You Live +WITCH WATCH +Lord Marksman and Vanadis +Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo +Tonari No Furi-San Ga Tonikaku Kowai +Hinowa ga CRUSH! +Tsuredure Children +Dance in the Vampire Bund +Sperman +The Rising Of The Shield Hero +Triage X +Kiruru Kill Me +Hidan no Aria AA +Origin +Senran Kagura - Skirting Shadows +Higurashi no Naku Koro ni - Himatsubushihen +APOSIMZ +Franken Fran +Is This a Zombie +School Rumble +Darker than Black - Shikkoku no Hana +Sweet X Trouble +Close As Neighbors +7SEEDS +Dungeon Seeker +Necromance +Code Breaker +Rokka Braves of the Six Flowers +Prison School +COPPELION +Grand Blue Dreaming +Libidors +Skill of Lure +Pluto - Urasawa x Tezuka +Chibi Vampire +Omamori Himari +"Zoku, Kono Subarashii Sekai ni Bakuen wo!" +"Please Go Home, Akutsu-San!" +Mahoutsukai to Teishi no Futekisetsu na Kankei +Chobits +The Seven Deadly Sins +Black Clover +We Never Learn +Tomogui Kyoushitsu +Tokyo Ghoul +Sweat and Soap +Seraph of the End - Vampire Reign 097 +Higurashi no Naku Koro ni Kai - Meakashihen +Children +"Can You Just Die, My Darling" +"Haganai, I Don't Have Many Friends" +Heion Sedai no Idaten-tachi +Baketeriya +Magical Sempai +Ajin - Demi-Human +Kimi wa Midara na Boku no Joou +DearS +Pluto +Lotte no Omocha! +Love Hina +Shoujo Kaitai +El Cazador de la Bruja +Akame ga KILL! ZERO +"Beauty, Sage And The Devil's Sword" +Higurashi no Naku Koro ni - Watanagashihen +Corpse Party - Musume +Getsuyoubi no Tawawa +Trinity Seven +"No Game, No Life" +KanoKari Mythology +Seraph of the End - Vampire Reign 094 +Uzumaki +Darling in the FranXX +The Blade Of Evolution-Walking Alone In The Dungeon +BLAME! Master Edition +Fire Force +Toukyou Akazukin +Darker than Black +Karin +Higurashi no Naku Koro ni Kai - Matsuribayashihen +Akazukin +Velvet Kiss +"Kanojo, Okarishimasu" +Teasing Master Takagi-san +The Hentai Prince and the Stony Cat +Corpse Party - Book of Shadows +.hackxxxx +Hachigatsu Kokonoka Boku wa Kimi ni Kuwareru. +Corpse Party - Blood Covered +King Of Thorn +BTOOOM! +Chimamire Sukeban Chainsaw +Seraph of the End - Vampire Reign +Juni Taisen Zodiac War +Masamune-kun's Revenge +How Many Light-Years to Babylon +Midori no Hibi +A Girl on the Shore +Plunderer +School Rumble - Pleasure File +Green WorldZ +Golden Boy +Yuusha ga Shinda! +Kodomo no Jikan +unOrdinary +My Wife is Wagatsuma-san +VanDread +Rosario+Vampire +Kyochuu Rettou +Deadman Wonderland +KILL la KILL +Mushoku Tensei - Jobless Reincarnation +404 Case Manual 30 Seconds Till Apocalypse +Iris Zero +All You Need is Kill +Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai Man-hen +High School DxD +Needless +Ichiban no Daimaou +My Girlfriend Is A Zombie +Hare-Kon +Minamoto-kun Monogatari +Batman Beyond 02 +Spawn +iZombie +Invincible 070.5 - Invincible Returns +Invincible Presents - Atom Eve +Invincible 033.5 - Marvel Team-Up +Invincible 031.5 - Image - Future Shock +Batman Wayne Family Adventures +Batman Beyond 04 +Batman Beyond 2.0 +Batman Beyond 03 +Batman Beyond 05 +Chew +Zombie Tramp vs. Vampblade TPB +Free Scott Pilgrim +Invincible Presents - Atom Eve & Rex Splode +Scott Pilgrim 03 - Scott Pilgrim & The Infinite Sadness +I Hate Fairyland +Scott Pilgrim 06 - Scott Pilgrim's Finest Hour +Scott Pilgrim 04 - Scott Pilgrim Gets It Together +Scott Pilgrim 01 - Scott Pilgrim's Precious Little Life +Spawn - 25th Anniversary Director's Cut +Zombie Tramp +Invincible Universe +The Official Handbook of the Invincible Universe +Batman Beyond +Saga +Scott Pilgrim 05 - Scott Pilgrim vs. the Universe +Batman Beyond 06 +Batman - Detective Comics - Rebirth Deluxe Edition Book +Batman Beyond 01 +Batman - Catwoman +Invincible 022.5 - Invincible +Teen Titans - Raven +Invincible 052 +Invincible 014.5 - Image Comics Summer +Zombie Tramp v3 TPB +Scott Pilgrim 02 - Scott Pilgrim vs. The World +Invincible +Spawn 220 +Y - The Last Man +Kick-Ass - The Dave Lizewski Years +Teen Titans +Fables +Book of Enoch +To Love-Ru Darkness - Digital Colored Comics +Medaka Box - Digital Colored Comics +Magical P tissi re Kosaki-chan!! +Pandora in the Crimson Shell - Ghost Urn +Yuragi-sou no Yuuna-san - Digital Colored Comics +Ziggurat +Tsugumomo - Digital Colored Comics +The War Poems Of Siegfried Sassoon +Rokka - Braves of the Six Flowers +Demon King Daimaou +Blockade Billy +Cujo +The Magicians +The Gunslinger +Danse Macabre +Christine +Fool moon +On Writing +Roadwork +Deep Learning with Python - A Hands-on Introduction +If It Bleeds +Night Shift +Bag of Bones +Dreamcatcher +Desperation +Duma Key +Four Past Midnight +Elevation +The Colorado Kid +The Eyes of the Dragon +Consulting With This Masked Devil! +Gifting the Wonderful World with Blessings! +The Golden Harpoon / Lost Among the Floes +Invaders of the Rokujouma +Cell +Uncollected Stories 2003 +Faithful +"Full Dark, No Stars" +Dolores Claiborne +It +Antonio's Tale +Joyland +konosuba +CSHP19 +By the Grace of the Gods - LN +EPUB 3 Collection +Talisman +Sword Art Online +The Mist +Insomnia +Hearts In Atlantis +11/22/63 +Kono Subarashii Sekai ni Bakuen wo! +In the Tall Grass +Nightmares and Dreamscapes +Eloquent JavaScript +The Bell Jar +Six Stories +Rose Madder +The Stand +The Devil Is a Part-Timer! +Grimgar of Fantasy and Ash +A Chronicle of the Last Pagans +Cycle of the Werewolf +Gifting this Wonderful World With Blessings! +Unit 1. Operations on Numbers. +Firestarter +The Dark Half +Accel World +Love Hina - Volume +Skeleton Crew +Needful Things +Kono Subarashii Sekai ni Syukufuku wo! +Carrie +Thinner +Hentai Ouji to Warawanai Neko +Blaze +Saturn Run +Throttle +Just After Sunset +Gerald's Game +The Regulators +Different Seasons +The Dark Tower +Pet Sematary +The Girl Who Loved Tom Gordon +Ano Orokamono ni mo Kyakkou wo! +From A Buick 8 +The Green Mile +"Celebration of Discipline, Special Anniversary Edition" +Combatants Will Be Dispatched! +Kore Wa Zombie Desu Ka +The Shining +The Tatami Galaxy +Salem's Lot +The Tommyknockers +A Face in the Crowd +UR +この素晴らしい世界に祝福を! 9 紅の宿命 【電子特別版】 +Outsider +Lisey's Story +Everything's Eventual +Dune +The Dead Zone +Mile 81 +Under the Dome +The Long Walk +The Running Man +EPUB3 UNLEASHED 2012 +Gifting The Wonderful World With Explosions! +Rage diff --git a/API.Benchmark/ParseScannedFilesBenchmarks.cs b/API.Benchmark/ParseScannedFilesBenchmarks.cs index d3fd19a4e..8681c1261 100644 --- a/API.Benchmark/ParseScannedFilesBenchmarks.cs +++ b/API.Benchmark/ParseScannedFilesBenchmarks.cs @@ -1,6 +1,4 @@ -using System; -using System.IO; -using API.Data; +using System.IO; using API.Entities.Enums; using API.Interfaces.Services; using API.Parser; @@ -57,8 +55,8 @@ namespace API.Benchmark Title = "A Town Where You Live", Volumes = "1" }; - var parsedSeries = _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new string[] {libraryPath}, - out var totalFiles, out var scanElapsedTime); + _parseScannedFiles.ScanLibrariesForSeries(LibraryType.Manga, new [] {libraryPath}, + out _, out _); _parseScannedFiles.MergeName(p1); } } diff --git a/API.Benchmark/ParserBenchmarks.cs b/API.Benchmark/ParserBenchmarks.cs new file mode 100644 index 000000000..ef12331cc --- /dev/null +++ b/API.Benchmark/ParserBenchmarks.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.RegularExpressions; +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Order; + +namespace API.Benchmark +{ + [MemoryDiagnoser] + [Orderer(SummaryOrderPolicy.FastestToSlowest)] + [RankColumn] + public class ParserBenchmarks + { + private readonly IList _names; + + private static readonly Regex NormalizeRegex = new Regex(@"[^a-zA-Z0-9]", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(300)); + + private static readonly Regex IsEpub = new Regex(@"\.epub", + RegexOptions.IgnoreCase | RegexOptions.Compiled, + TimeSpan.FromMilliseconds(300)); + + public ParserBenchmarks() + { + // Read all series from SeriesNamesForNormalization.txt + _names = File.ReadAllLines("Data/SeriesNamesForNormalization.txt"); + Console.WriteLine($"Performing benchmark on {_names.Count} series"); + } + + private static void NormalizeOriginal(string name) + { + Regex.Replace(name.ToLower(), "[^a-zA-Z0-9]", string.Empty); + } + + private static void NormalizeNew(string name) + { + // ReSharper disable once UnusedVariable + var ret = NormalizeRegex.Replace(name, string.Empty).ToLower(); + } + + + [Benchmark] + public void TestNormalizeName() + { + foreach (var name in _names) + { + NormalizeOriginal(name); + } + } + + + [Benchmark] + public void TestNormalizeName_New() + { + foreach (var name in _names) + { + NormalizeNew(name); + } + } + + [Benchmark] + public void TestIsEpub() + { + foreach (var name in _names) + { + if ((name + ".epub").ToLower() == ".epub") + { + /* No Operation */ + } + } + } + + [Benchmark] + public void TestIsEpub_New() + { + foreach (var name in _names) + { + + if (IsEpub.IsMatch((name + ".epub"))) + { + /* No Operation */ + } + } + } + + + } +} diff --git a/API.Benchmark/Program.cs b/API.Benchmark/Program.cs index b308a07b7..c3ef1b605 100644 --- a/API.Benchmark/Program.cs +++ b/API.Benchmark/Program.cs @@ -10,10 +10,12 @@ namespace API.Benchmark /// public static class Program { - static void Main(string[] args) + private static void Main(string[] args) { - BenchmarkRunner.Run(); + //BenchmarkRunner.Run(); //BenchmarkRunner.Run(); + BenchmarkRunner.Run(); + } } } diff --git a/API.Tests/API.Tests.csproj b/API.Tests/API.Tests.csproj index e01bab216..59ecff406 100644 --- a/API.Tests/API.Tests.csproj +++ b/API.Tests/API.Tests.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/API.Tests/Parser/BookParserTests.cs b/API.Tests/Parser/BookParserTests.cs index 219f5d723..b33ef1f54 100644 --- a/API.Tests/Parser/BookParserTests.cs +++ b/API.Tests/Parser/BookParserTests.cs @@ -10,5 +10,12 @@ namespace API.Tests.Parser { Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename)); } + + [Theory] + [InlineData("Harrison, Kim - Dates from Hell - Hollows Vol 2.5.epub", "2.5")] + public void ParseVolumeTest(string filename, string expected) + { + Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename)); + } } } diff --git a/API.Tests/Parser/ComicParserTests.cs b/API.Tests/Parser/ComicParserTests.cs index 8d25661ff..5bd24f714 100644 --- a/API.Tests/Parser/ComicParserTests.cs +++ b/API.Tests/Parser/ComicParserTests.cs @@ -1,11 +1,22 @@ -using Xunit; +using System; +using System.Collections.Generic; +using API.Entities.Enums; +using API.Parser; +using Xunit; +using Xunit.Abstractions; namespace API.Tests.Parser { public class ComicParserTests { + private readonly ITestOutputHelper _testOutputHelper; + + public ComicParserTests(ITestOutputHelper testOutputHelper) + { + _testOutputHelper = testOutputHelper; + } + [Theory] - [InlineData("01 Spider-Man & Wolverine 01.cbr", "Spider-Man & Wolverine")] [InlineData("04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS)", "Asterix the Gladiator")] [InlineData("The First Asterix Frieze (WebP by Doc MaKS)", "The First Asterix Frieze")] [InlineData("Batman & Catwoman - Trail of the Gun 01", "Batman & Catwoman - Trail of the Gun")] @@ -28,7 +39,23 @@ namespace API.Tests.Parser [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "Invincible")] [InlineData("Batman Wayne Family Adventures - Ep. 001 - Moving In", "Batman Wayne Family Adventures")] [InlineData("Saga 001 (2012) (Digital) (Empire-Zone).cbr", "Saga")] + [InlineData("spawn-123", "spawn")] + [InlineData("spawn-chapter-123", "spawn")] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "Spawn")] [InlineData("Batman Beyond 04 (of 6) (1999)", "Batman Beyond")] + [InlineData("Batman Beyond 001 (2012)", "Batman Beyond")] + [InlineData("Batman Beyond 2.0 001 (2013)", "Batman Beyond 2.0")] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "Batman - Catwoman")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "Chew")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "Chew Script Book")] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", "Batman - Detective Comics - Rebirth Deluxe Edition Book")] + [InlineData("Cyberpunk 2077 - Your Voice #01", "Cyberpunk 2077 - Your Voice")] + [InlineData("Cyberpunk 2077 #01", "Cyberpunk 2077")] + [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "Cyberpunk 2077 - Trauma Team")] + [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "Batgirl")] + [InlineData("Batgirl V2000 #57", "Batgirl")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire)", "Fables")] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "2000 AD")] public void ParseComicSeriesTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename)); @@ -52,6 +79,20 @@ namespace API.Tests.Parser [InlineData("Amazing Man Comics chapter 25", "0")] [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")] [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] + [InlineData("spawn-123", "0")] + [InlineData("spawn-chapter-123", "0")] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "0")] + [InlineData("Batman Beyond 04 (of 6) (1999)", "0")] + [InlineData("Batman Beyond 001 (2012)", "0")] + [InlineData("Batman Beyond 2.0 001 (2013)", "0")] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "0")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "1")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] + [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "2000")] + [InlineData("Batgirl V2000 #57", "2000")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "0")] + [InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "0")] public void ParseComicVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicVolume(filename)); @@ -77,12 +118,87 @@ namespace API.Tests.Parser [InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "33.5")] [InlineData("Batman Wayne Family Adventures - Ep. 014 - Moving In", "14")] [InlineData("Saga 001 (2012) (Digital) (Empire-Zone)", "1")] + [InlineData("spawn-123", "123")] + [InlineData("spawn-chapter-123", "123")] + [InlineData("Spawn 062 (1997) (digital) (TLK-EMPIRE-HD).cbr", "62")] [InlineData("Batman Beyond 04 (of 6) (1999)", "4")] [InlineData("Invincible 052 (c2c) (2008) (Minutemen-TheCouple)", "52")] [InlineData("Y - The Last Man #001", "1")] + [InlineData("Batman Beyond 001 (2012)", "1")] + [InlineData("Batman Beyond 2.0 001 (2013)", "1")] + [InlineData("Batman - Catwoman 001 (2021) (Webrip) (The Last Kryptonian-DCP)", "1")] + [InlineData("Chew v1 - Taster´s Choise (2012) (Digital) (1920) (Kingpin-Empire)", "0")] + [InlineData("Chew Script Book (2011) (digital-Empire) SP04", "0")] + [InlineData("Batgirl Vol.2000 #57 (December, 2004)", "57")] + [InlineData("Batgirl V2000 #57", "57")] + [InlineData("Fables 021 (2004) (Digital) (Nahga-Empire).cbr", "21")] + [InlineData("Cyberpunk 2077 - Trauma Team #04.cbz", "4")] + [InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "366")] public void ParseComicChapterTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseComicChapter(filename)); } + + + [Theory] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 02 (2018) (digital) (Son of Ultron-Empire)", true)] + [InlineData("Zombie Tramp vs. Vampblade TPB (2016) (Digital) (TheArchivist-Empire)", true)] + [InlineData("Baldwin the Brave & Other Tales Special SP1.cbr", true)] + [InlineData("Mouse Guard Specials - Spring 1153 - Fraggle Rock FCBD 2010", true)] + public void ParseComicSpecialTest(string input, bool expected) + { + Assert.Equal(expected, !string.IsNullOrEmpty(API.Parser.Parser.ParseComicSpecial(input))); + } + + [Fact] + public void ParseInfoTest() + { + const string rootPath = @"E:/Comics/"; + var expected = new Dictionary(); + var filepath = @"E:/Comics/Teen Titans/Teen Titans v1 Annual 01 (1967) SP01.cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Teen Titans", Volumes = "0", + Chapters = "0", Filename = "Teen Titans v1 Annual 01 (1967) SP01.cbr", Format = MangaFormat.Archive, + FullFilePath = filepath + }); + + // Fallback test with bad naming + filepath = @"E:\Comics\Comics\Babe\Babe Vol.1 #1-4\Babe 01.cbr"; + expected.Add(filepath, new ParserInfo + { + Series = "Babe", Volumes = "0", Edition = "", + Chapters = "1", Filename = "Babe 01.cbr", Format = MangaFormat.Archive, + FullFilePath = filepath, IsSpecial = false + }); + + foreach (var file in expected.Keys) + { + var expectedInfo = expected[file]; + var actual = API.Parser.Parser.Parse(file, rootPath); + if (expectedInfo == null) + { + Assert.Null(actual); + return; + } + Assert.NotNull(actual); + _testOutputHelper.WriteLine($"Validating {file}"); + Assert.Equal(expectedInfo.Format, actual.Format); + _testOutputHelper.WriteLine("Format ✓"); + Assert.Equal(expectedInfo.Series, actual.Series); + _testOutputHelper.WriteLine("Series ✓"); + Assert.Equal(expectedInfo.Chapters, actual.Chapters); + _testOutputHelper.WriteLine("Chapters ✓"); + Assert.Equal(expectedInfo.Volumes, actual.Volumes); + _testOutputHelper.WriteLine("Volumes ✓"); + Assert.Equal(expectedInfo.Edition, actual.Edition); + _testOutputHelper.WriteLine("Edition ✓"); + Assert.Equal(expectedInfo.Filename, actual.Filename); + _testOutputHelper.WriteLine("Filename ✓"); + Assert.Equal(expectedInfo.FullFilePath, actual.FullFilePath); + _testOutputHelper.WriteLine("FullFilePath ✓"); + } + } + } } diff --git a/API.Tests/Parser/MangaParserTests.cs b/API.Tests/Parser/MangaParserTests.cs index 917d1f467..9cb9d560a 100644 --- a/API.Tests/Parser/MangaParserTests.cs +++ b/API.Tests/Parser/MangaParserTests.cs @@ -67,6 +67,7 @@ namespace API.Tests.Parser [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")] + [InlineData("The 100 Girlfriends Who Really, Really, Really, Really, Really Love You - Vol. 03.5 Ch. 023.5 - Volume 3 Extras.cbz", "3.5")] public void ParseVolumeTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename)); @@ -238,6 +239,7 @@ namespace API.Tests.Parser [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", "0")] public void ParseChaptersTest(string filename, string expected) { Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename)); @@ -291,18 +293,6 @@ namespace API.Tests.Parser Assert.Equal(expected, API.Parser.Parser.ParseMangaSpecial(inputFile)); } -/* - private static ParserInfo CreateParserInfo(string series, string chapter, string volume, bool isSpecial = false) - { - return new ParserInfo() - { - Chapters = chapter, - Volumes = volume, - IsSpecial = isSpecial, - Series = series, - }; - } -*/ [Theory] [InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")] @@ -436,6 +426,14 @@ namespace API.Tests.Parser FullFilePath = filepath, IsSpecial = false }); + filepath = @"E:\Manga\Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub"; + expected.Add(filepath, new ParserInfo + { + Series = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows", Volumes = "2.5", Edition = "", + Chapters = "0", Filename = "Harrison, Kim - The Good, The Bad, and the Undead - Hollows Vol 2.5.epub", Format = MangaFormat.Epub, + FullFilePath = filepath, IsSpecial = false + }); + // If an image is cover exclusively, ignore it filepath = @"E:\Manga\Seraph of the End\cover.png"; expected.Add(filepath, null); diff --git a/API.Tests/Parser/ParserTest.cs b/API.Tests/Parser/ParserTest.cs index 6830cde0d..8fdf0509d 100644 --- a/API.Tests/Parser/ParserTest.cs +++ b/API.Tests/Parser/ParserTest.cs @@ -11,6 +11,7 @@ namespace API.Tests.Parser [InlineData("Beastars SP01", true)] [InlineData("Beastars Special 01", false)] [InlineData("Beastars Extra 01", false)] + [InlineData("Batman Beyond - Return of the Joker (2001) SP01", true)] public void HasSpecialTest(string input, bool expected) { Assert.Equal(expected, HasSpecialMarker(input)); @@ -35,14 +36,15 @@ namespace API.Tests.Parser } [Theory] - [InlineData("Hello_I_am_here", "Hello I am here")] - [InlineData("Hello_I_am_here ", "Hello I am here")] - [InlineData("[ReleaseGroup] The Title", "The Title")] - [InlineData("[ReleaseGroup]_The_Title", "The Title")] - [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", "Kasumi Otoko no Ko v1.1")] - public void CleanTitleTest(string input, string expected) + [InlineData("Hello_I_am_here", false, "Hello I am here")] + [InlineData("Hello_I_am_here ", false, "Hello I am here")] + [InlineData("[ReleaseGroup] The Title", false, "The Title")] + [InlineData("[ReleaseGroup]_The_Title", false, "The Title")] + [InlineData("[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1", false, "Kasumi Otoko no Ko v1.1")] + [InlineData("Batman - Detective Comics - Rebirth Deluxe Edition Book 04 (2019) (digital) (Son of Ultron-Empire)", true, "Batman - Detective Comics - Rebirth Deluxe Edition")] + public void CleanTitleTest(string input, bool isComic, string expected) { - Assert.Equal(expected, CleanTitle(input)); + Assert.Equal(expected, CleanTitle(input, isComic)); } @@ -54,7 +56,7 @@ namespace API.Tests.Parser // public void ReplaceStyleUrlTest(string input, string expected) // { // var replacementStr = "PaytoneOne.ttf"; - // // TODO: Use Match to validate since replace is weird + // // Use Match to validate since replace is weird // //Assert.Equal(expected, FontSrcUrlRegex.Replace(input, "$1" + replacementStr + "$2" + "$3")); // var match = FontSrcUrlRegex.Match(input); // Assert.Equal(!string.IsNullOrEmpty(expected), FontSrcUrlRegex.Match(input).Success); @@ -98,33 +100,6 @@ namespace API.Tests.Parser Assert.Equal(expected, IsEpub(input)); } - // [Theory] - // [InlineData("Tenjou Tenge Omnibus", "Omnibus")] - // [InlineData("Tenjou Tenge {Full Contact Edition}", "Full Contact Edition")] - // [InlineData("Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz", "Full Contact Edition")] - // [InlineData("Wotakoi - Love is Hard for Otaku Omnibus v01 (2018) (Digital) (danke-Empire)", "Omnibus")] - // [InlineData("To Love Ru v01 Uncensored (Ch.001-007)", "Uncensored")] - // [InlineData("Chobits Omnibus Edition v01 [Dark Horse]", "Omnibus Edition")] - // [InlineData("[dmntsf.net] One Piece - Digital Colored Comics Vol. 20 Ch. 177 - 30 Million vs 81 Million.cbz", "Digital Colored Comics")] - // [InlineData("AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz", "Full Color")] - // public void ParseEditionTest(string input, string expected) - // { - // Assert.Equal(expected, ParseEdition(input)); - // } - - // [Theory] - // [InlineData("Beelzebub Special OneShot - Minna no Kochikame x Beelzebub (2016) [Mangastream].cbz", true)] - // [InlineData("Beelzebub_Omake_June_2012_RHS", true)] - // [InlineData("Beelzebub_Side_Story_02_RHS.zip", false)] - // [InlineData("Darker than Black Shikkoku no Hana Special [Simple Scans].zip", true)] - // [InlineData("Darker than Black Shikkoku no Hana Fanbook Extra [Simple Scans].zip", true)] - // [InlineData("Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Extra Chapter", true)] - // [InlineData("Ani-Hina Art Collection.cbz", true)] - // public void ParseMangaSpecialTest(string input, bool expected) - // { - // Assert.Equal(expected, ParseMangaSpecial(input) != ""); - // } - [Theory] [InlineData("12-14", 12)] [InlineData("24", 24)] @@ -142,6 +117,8 @@ namespace API.Tests.Parser [InlineData("Darker Than Black", "darkerthanblack")] [InlineData("Darker Than Black - Something", "darkerthanblacksomething")] [InlineData("Darker Than_Black", "darkerthanblack")] + [InlineData("Citrus", "citrus")] + [InlineData("Citrus+", "citrus+")] [InlineData("", "")] public void NormalizeTest(string input, string expected) { diff --git a/API.Tests/Services/ArchiveServiceTests.cs b/API.Tests/Services/ArchiveServiceTests.cs index 80f09a144..fc3e21dd4 100644 --- a/API.Tests/Services/ArchiveServiceTests.cs +++ b/API.Tests/Services/ArchiveServiceTests.cs @@ -2,6 +2,7 @@ using System.IO; using System.IO.Compression; using API.Archive; +using API.Data.Metadata; using API.Interfaces.Services; using API.Services; using Microsoft.Extensions.Logging; @@ -216,8 +217,30 @@ namespace API.Tests.Services var archive = Path.Join(testDirectory, "file in folder.zip"); var summaryInfo = "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?"; - Assert.Equal(summaryInfo, _archiveService.GetSummaryInfo(archive)); + Assert.Equal(summaryInfo, _archiveService.GetComicInfo(archive).Summary); + } + [Fact] + public void CanParseComicInfo() + { + var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/ComicInfos"); + var archive = Path.Join(testDirectory, "ComicInfo.zip"); + var actual = _archiveService.GetComicInfo(archive); + var expected = new ComicInfo() + { + Publisher = "Yen Press", + Genre = "Manga, Movies & TV", + Summary = + "By all counts, Ryouta Sakamoto is a loser when he's not holed up in his room, bombing things into oblivion in his favorite online action RPG. But his very own uneventful life is blown to pieces when he's abducted and taken to an uninhabited island, where he soon learns the hard way that he's being pitted against others just like him in a explosives-riddled death match! How could this be happening? Who's putting them up to this? And why!? The name, not to mention the objective, of this very real survival game is eerily familiar to Ryouta, who has mastered its virtual counterpart-BTOOOM! Can Ryouta still come out on top when he's playing for his life!?", + PageCount = 194, + LanguageISO = "en", + Notes = "Scraped metadata from Comixology [CMXDB450184]", + Series = "BTOOOM!", + Title = "v01", + Web = "https://www.comixology.com/BTOOOM/digital-comic/450184" + }; + + Assert.NotStrictEqual(expected, actual); } } } diff --git a/API.Tests/Services/DirectoryServiceTests.cs b/API.Tests/Services/DirectoryServiceTests.cs index db756ebab..90cf1a217 100644 --- a/API.Tests/Services/DirectoryServiceTests.cs +++ b/API.Tests/Services/DirectoryServiceTests.cs @@ -90,7 +90,7 @@ namespace API.Tests.Services } [Theory] - [InlineData(new string[] {"C:/Manga/"}, new string[] {"C:/Manga/Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] + [InlineData(new [] {"C:/Manga/"}, new [] {"C:/Manga/Love Hina/Vol. 01.cbz"}, "C:/Manga/Love Hina")] public void FindHighestDirectoriesFromFilesTest(string[] rootDirectories, string[] folders, string expectedDirectory) { var actual = DirectoryService.FindHighestDirectoriesFromFiles(rootDirectories, folders); diff --git a/API.Tests/Services/MetadataServiceTests.cs b/API.Tests/Services/MetadataServiceTests.cs index b921f74b7..5d61ee249 100644 --- a/API.Tests/Services/MetadataServiceTests.cs +++ b/API.Tests/Services/MetadataServiceTests.cs @@ -1,13 +1,7 @@ using System; using System.IO; using API.Entities; -using API.Interfaces; -using API.Interfaces.Services; using API.Services; -using API.SignalR; -using Microsoft.AspNetCore.SignalR; -using Microsoft.Extensions.Logging; -using NSubstitute; using Xunit; namespace API.Tests.Services diff --git a/API.Tests/Services/ScannerServiceTests.cs b/API.Tests/Services/ScannerServiceTests.cs index 93b254c8e..0253ccef6 100644 --- a/API.Tests/Services/ScannerServiceTests.cs +++ b/API.Tests/Services/ScannerServiceTests.cs @@ -111,7 +111,7 @@ namespace API.Tests.Services - Assert.Empty(_scannerService.FindSeriesNotOnDisk(existingSeries, infos)); + Assert.Empty(ScannerService.FindSeriesNotOnDisk(existingSeries, infos)); } diff --git a/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo.zip b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo.zip new file mode 100644 index 000000000..3ff536270 Binary files /dev/null and b/API.Tests/Services/Test Data/ArchiveService/ComicInfos/ComicInfo.zip differ diff --git a/API/API.csproj b/API/API.csproj index fa1bcdfec..137a3a985 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -16,6 +16,10 @@ bin\Debug\API.xml + + en + + Kavita @@ -33,39 +37,38 @@ - + - - + + - + - - - + + + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/API/Controllers/AccountController.cs b/API/Controllers/AccountController.cs index 58478e2f8..86b1ac778 100644 --- a/API/Controllers/AccountController.cs +++ b/API/Controllers/AccountController.cs @@ -7,10 +7,10 @@ using API.Constants; using API.DTOs; using API.DTOs.Account; using API.Entities; -using API.Errors; using API.Extensions; using API.Interfaces; using API.Interfaces.Services; +using API.Services; using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Identity; @@ -31,13 +31,14 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IMapper _mapper; + private readonly IAccountService _accountService; /// public AccountController(UserManager userManager, SignInManager signInManager, ITokenService tokenService, IUnitOfWork unitOfWork, ILogger logger, - IMapper mapper) + IMapper mapper, IAccountService accountService) { _userManager = userManager; _signInManager = signInManager; @@ -45,6 +46,7 @@ namespace API.Controllers _unitOfWork = unitOfWork; _logger = logger; _mapper = mapper; + _accountService = accountService; } /// @@ -61,30 +63,10 @@ namespace API.Controllers if (resetPasswordDto.UserName != User.GetUsername() && !User.IsInRole(PolicyConstants.AdminRole)) return Unauthorized("You are not permitted to this operation."); - // Validate Password - foreach (var validator in _userManager.PasswordValidators) + var errors = await _accountService.ChangeUserPassword(user, resetPasswordDto.Password); + if (errors.Any()) { - var validationResult = await validator.ValidateAsync(_userManager, user, resetPasswordDto.Password); - if (!validationResult.Succeeded) - { - return BadRequest( - validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description))); - } - } - - var result = await _userManager.RemovePasswordAsync(user); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description))); - } - - - result = await _userManager.AddPasswordAsync(user, resetPasswordDto.Password); - if (!result.Succeeded) - { - _logger.LogError("Could not update password"); - return BadRequest(result.Errors.Select(e => new ApiException(400, e.Code, e.Description))); + return BadRequest(errors); } _logger.LogInformation("{User}'s Password has been reset", resetPasswordDto.UserName); @@ -110,6 +92,13 @@ namespace API.Controllers user.UserPreferences ??= new AppUserPreferences(); user.ApiKey = HashUtil.ApiKey(); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.EnableAuthentication && !registerDto.IsAdmin) + { + _logger.LogInformation("User {UserName} is being registered as non-admin with no server authentication. Using default password.", registerDto.Username); + registerDto.Password = AccountService.DefaultPassword; + } + var result = await _userManager.CreateAsync(user, registerDto.Password); if (!result.Succeeded) return BadRequest(result.Errors); @@ -166,6 +155,14 @@ namespace API.Controllers if (user == null) return Unauthorized("Invalid username"); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); + var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (!settings.EnableAuthentication && !isAdmin) + { + _logger.LogDebug("User {UserName} is logging in with authentication disabled", loginDto.Username); + loginDto.Password = AccountService.DefaultPassword; + } + var result = await _signInManager .CheckPasswordSignInAsync(user, loginDto.Password, false); diff --git a/API/Controllers/CollectionController.cs b/API/Controllers/CollectionController.cs index 6081f7d58..049413388 100644 --- a/API/Controllers/CollectionController.cs +++ b/API/Controllers/CollectionController.cs @@ -2,13 +2,11 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.Constants; using API.DTOs; using API.Entities; using API.Extensions; using API.Interfaces; using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -19,13 +17,11 @@ namespace API.Controllers public class CollectionController : BaseApiController { private readonly IUnitOfWork _unitOfWork; - private readonly UserManager _userManager; /// - public CollectionController(IUnitOfWork unitOfWork, UserManager userManager) + public CollectionController(IUnitOfWork unitOfWork) { _unitOfWork = unitOfWork; - _userManager = userManager; } /// @@ -36,7 +32,7 @@ namespace API.Controllers public async Task> GetAllTags() { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername()); - var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); if (isAdmin) { return await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); diff --git a/API/Controllers/DownloadController.cs b/API/Controllers/DownloadController.cs index 3000e1f22..d5080846a 100644 --- a/API/Controllers/DownloadController.cs +++ b/API/Controllers/DownloadController.cs @@ -63,7 +63,7 @@ namespace API.Controllers public async Task DownloadVolume(int volumeId) { var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId); - var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(volumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { @@ -92,7 +92,7 @@ namespace API.Controllers { var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId); - var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); try { diff --git a/API/Controllers/ImageController.cs b/API/Controllers/ImageController.cs index f1ddb770e..88bafcff7 100644 --- a/API/Controllers/ImageController.cs +++ b/API/Controllers/ImageController.cs @@ -1,12 +1,9 @@ -using System; -using System.IO; -using System.Net; +using System.IO; using System.Threading.Tasks; using API.Extensions; using API.Interfaces; using API.Services; using Microsoft.AspNetCore.Mvc; -using Microsoft.Net.Http.Headers; namespace API.Controllers { diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 25f224a28..07a4a3f97 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Data.Repositories; using API.DTOs; using API.Entities; using API.Entities.Enums; @@ -179,7 +180,7 @@ namespace API.Controllers try { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); _unitOfWork.LibraryRepository.Delete(library); await _unitOfWork.CommitAsync(); @@ -203,7 +204,7 @@ namespace API.Controllers [HttpPost("update")] public async Task UpdateLibrary(UpdateLibraryDto libraryForUserDto) { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id, LibraryIncludes.Folders); var originalFolders = library.Folders.Select(x => x.Path).ToList(); diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index ef83c2a69..c9e527e2c 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading.Tasks; using System.Xml.Serialization; using API.Comparators; -using API.Constants; using API.DTOs; using API.DTOs.Filtering; using API.DTOs.OPDS; @@ -16,7 +15,6 @@ using API.Interfaces; using API.Interfaces.Services; using API.Services; using Kavita.Common; -using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace API.Controllers @@ -26,7 +24,6 @@ namespace API.Controllers private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; - private readonly UserManager _userManager; private readonly ICacheService _cacheService; private readonly IReaderService _readerService; @@ -41,13 +38,12 @@ namespace API.Controllers private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer(); public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService, - IDirectoryService directoryService, UserManager userManager, - ICacheService cacheService, IReaderService readerService) + IDirectoryService directoryService, ICacheService cacheService, + IReaderService readerService) { _unitOfWork = unitOfWork; _downloadService = downloadService; _directoryService = directoryService; - _userManager = userManager; _cacheService = cacheService; _readerService = readerService; @@ -170,16 +166,16 @@ namespace API.Controllers return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); - IEnumerable tags; + IList tags; if (isAdmin) { - tags = await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync(); + tags = (await _unitOfWork.CollectionTagRepository.GetAllTagDtosAsync()).ToList(); } else { - tags = await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync(); + tags = (await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync()).ToList(); } @@ -201,6 +197,14 @@ namespace API.Controllers }); } + if (tags.Count == 0) + { + feed.Entries.Add(new FeedEntry() + { + Title = "Nothing here", + }); + } + return CreateXmlResult(SerializeXml(feed)); } @@ -213,7 +217,7 @@ namespace API.Controllers return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId); - var isAdmin = await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + var isAdmin = await _unitOfWork.UserRepository.IsUserAdmin(user); IEnumerable tags; if (isAdmin) @@ -300,13 +304,13 @@ namespace API.Controllers var feed = CreateFeed(readingList.Title + " Reading List", $"{apiKey}/reading-list/{readingListId}", apiKey); - var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId); + var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList(); foreach (var item in items) { feed.Entries.Add(new FeedEntry() { Id = item.ChapterId.ToString(), - Title = "Chapter " + item.ChapterNumber, + Title = $"{item.SeriesName} Chapter {item.ChapterNumber}", Links = new List() { CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{item.SeriesId}/volume/{item.VolumeId}/chapter/{item.ChapterId}"), @@ -315,6 +319,14 @@ namespace API.Controllers }); } + if (items.Count == 0) + { + feed.Entries.Add(new FeedEntry() + { + Title = "Nothing here", + }); + } + return CreateXmlResult(SerializeXml(feed)); @@ -373,6 +385,14 @@ namespace API.Controllers feed.Entries.Add(CreateSeries(seriesDto, apiKey)); } + if (recentlyAdded.Count == 0) + { + feed.Entries.Add(new FeedEntry() + { + Title = "Nothing here", + }); + } + return CreateXmlResult(SerializeXml(feed)); } @@ -404,6 +424,14 @@ namespace API.Controllers feed.Entries.Add(CreateSeries(seriesDto, apiKey)); } + if (pagedList.Count == 0) + { + feed.Entries.Add(new FeedEntry() + { + Title = "Nothing here", + }); + } + return CreateXmlResult(SerializeXml(feed)); } @@ -467,7 +495,7 @@ namespace API.Controllers return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId); var feed = CreateFeed(series.Name + " - Volumes", $"{apiKey}/series/{series.Id}", apiKey); feed.Links.Add(CreateLink(FeedLinkRelation.Image, FeedLinkType.Image, $"/api/image/series-cover?seriesId={seriesId}")); foreach (var volumeDto in volumes) @@ -486,7 +514,7 @@ namespace API.Controllers return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number), _chapterSortComparer); @@ -517,7 +545,7 @@ namespace API.Controllers return BadRequest("OPDS is not enabled on this server"); var userId = await GetUser(apiKey); var series = await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, userId); - var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(volumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId); var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId); var files = await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId); diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index d1314674e..5b18cfb98 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -97,7 +97,7 @@ namespace API.Controllers public async Task MarkRead(MarkReadDto markReadDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId); + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId); user.Progresses ??= new List(); foreach (var volume in volumes) { @@ -125,7 +125,7 @@ namespace API.Controllers public async Task MarkUnread(MarkReadDto markReadDto) { var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); - var volumes = await _unitOfWork.SeriesRepository.GetVolumes(markReadDto.SeriesId); + var volumes = await _unitOfWork.VolumeRepository.GetVolumes(markReadDto.SeriesId); user.Progresses ??= new List(); foreach (var volume in volumes) { @@ -267,7 +267,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); user.Progresses ??= new List(); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); foreach (var volume in volumes) { _readerService.MarkChaptersAsRead(user, volume.SeriesId, volume.Chapters); @@ -294,7 +294,7 @@ namespace API.Controllers var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress); user.Progresses ??= new List(); - var volumes = await _unitOfWork.SeriesRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); + var volumes = await _unitOfWork.VolumeRepository.GetVolumesForSeriesAsync(dto.SeriesIds.ToArray(), true); foreach (var volume in volumes) { _readerService.MarkChaptersAsUnread(user, volume.SeriesId, volume.Chapters); diff --git a/API/Controllers/ReadingListController.cs b/API/Controllers/ReadingListController.cs index 1f22263c7..19e4a4b49 100644 --- a/API/Controllers/ReadingListController.cs +++ b/API/Controllers/ReadingListController.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -99,16 +100,20 @@ namespace API.Controllers [HttpPost("delete-item")] public async Task DeleteListItem(UpdateReadingListPosition dto) { - var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemsByIdAsync(dto.ReadingListId)).ToList(); - var item = items.Find(r => r.Id == dto.ReadingListItemId); - items.Remove(item); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(dto.ReadingListId); + readingList.Items = readingList.Items.Where(r => r.Id != dto.ReadingListItemId).ToList(); - for (var i = 0; i < items.Count; i++) + + var index = 0; + foreach (var readingListItem in readingList.Items) { - items[i].Order = i; + readingListItem.Order = index; + index++; } - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + if (!_unitOfWork.HasChanges()) return Ok(); + + if (await _unitOfWork.CommitAsync()) { return Ok("Updated"); } @@ -138,15 +143,10 @@ namespace API.Controllers itemIdsToRemove.Contains(r.Id)); _unitOfWork.ReadingListRepository.BulkRemove(listItems); - if (_unitOfWork.HasChanges()) - { - await _unitOfWork.CommitAsync(); - return Ok("Updated"); - } - else - { - return Ok("Nothing to remove"); - } + if (!_unitOfWork.HasChanges()) return Ok("Nothing to remove"); + + await _unitOfWork.CommitAsync(); + return Ok("Updated"); } catch { @@ -437,7 +437,7 @@ namespace API.Controllers var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet(); var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds)) - .OrderBy(c => int.Parse(c.Volume.Name)) + .OrderBy(c => float.Parse(c.Volume.Name)) .ThenBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting); var index = lastOrder + 1; diff --git a/API/Controllers/SeriesController.cs b/API/Controllers/SeriesController.cs index ff0fa7587..cce0de2b5 100644 --- a/API/Controllers/SeriesController.cs +++ b/API/Controllers/SeriesController.cs @@ -10,9 +10,11 @@ using API.Entities; using API.Extensions; using API.Helpers; using API.Interfaces; +using API.SignalR; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; namespace API.Controllers @@ -22,12 +24,14 @@ namespace API.Controllers private readonly ILogger _logger; private readonly ITaskScheduler _taskScheduler; private readonly IUnitOfWork _unitOfWork; + private readonly IHubContext _messageHub; - public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork) + public SeriesController(ILogger logger, ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IHubContext messageHub) { _logger = logger; _taskScheduler = taskScheduler; _unitOfWork = unitOfWork; + _messageHub = messageHub; } [HttpPost] @@ -97,14 +101,14 @@ namespace API.Controllers public async Task>> GetVolumes(int seriesId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)); + return Ok(await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)); } [HttpGet("volume")] public async Task> GetVolume(int volumeId) { var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); - return Ok(await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(volumeId, userId)); + return Ok(await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId)); } [HttpGet("chapter")] @@ -217,7 +221,7 @@ namespace API.Controllers [HttpPost("refresh-metadata")] public ActionResult RefreshSeriesMetadata(RefreshSeriesDto refreshSeriesDto) { - _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId); + _taskScheduler.RefreshSeriesMetadata(refreshSeriesDto.LibraryId, refreshSeriesDto.SeriesId, true); return Ok(); } @@ -296,6 +300,12 @@ namespace API.Controllers if (await _unitOfWork.CommitAsync()) { + foreach (var tag in updateSeriesMetadataDto.Tags) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAddedToCollection, + MessageFactory.SeriesAddedToCollection(tag.Id, + updateSeriesMetadataDto.SeriesMetadata.SeriesId)); + } return Ok("Successfully updated"); } } diff --git a/API/Controllers/SettingsController.cs b/API/Controllers/SettingsController.cs index acd1b61e8..ef3fe8997 100644 --- a/API/Controllers/SettingsController.cs +++ b/API/Controllers/SettingsController.cs @@ -3,11 +3,13 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; -using API.DTOs; +using API.DTOs.Settings; using API.Entities.Enums; using API.Extensions; using API.Helpers.Converters; using API.Interfaces; +using API.Interfaces.Services; +using API.Services; using Kavita.Common; using Kavita.Common.Extensions; using Microsoft.AspNetCore.Authorization; @@ -21,12 +23,22 @@ namespace API.Controllers private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly ITaskScheduler _taskScheduler; + private readonly IAccountService _accountService; - public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler) + public SettingsController(ILogger logger, IUnitOfWork unitOfWork, ITaskScheduler taskScheduler, IAccountService accountService) { _logger = logger; _unitOfWork = unitOfWork; _taskScheduler = taskScheduler; + _accountService = accountService; + } + + [AllowAnonymous] + [HttpGet("base-url")] + public async Task> GetBaseUrl() + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settingsDto.BaseUrl); } [Authorize(Policy = "RequireAdminRole")] @@ -57,6 +69,7 @@ namespace API.Controllers // We do not allow CacheDirectory changes, so we will ignore. var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync(); + var updateAuthentication = false; foreach (var setting in currentSettings) { @@ -80,6 +93,18 @@ namespace API.Controllers _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value) + { + var path = !updateSettingsDto.BaseUrl.StartsWith("/") + ? $"/{updateSettingsDto.BaseUrl}" + : updateSettingsDto.BaseUrl; + path = !path.EndsWith("/") + ? $"{path}/" + : path; + setting.Value = path; + _unitOfWork.SettingsRepository.Update(setting); + } + if (setting.Key == ServerSettingKey.LoggingLevel && updateSettingsDto.LoggingLevel + string.Empty != setting.Value) { setting.Value = updateSettingsDto.LoggingLevel + string.Empty; @@ -93,6 +118,13 @@ namespace API.Controllers _unitOfWork.SettingsRepository.Update(setting); } + if (setting.Key == ServerSettingKey.EnableAuthentication && updateSettingsDto.EnableAuthentication + string.Empty != setting.Value) + { + setting.Value = updateSettingsDto.EnableAuthentication + string.Empty; + _unitOfWork.SettingsRepository.Update(setting); + updateAuthentication = true; + } + if (setting.Key == ServerSettingKey.AllowStatCollection && updateSettingsDto.AllowStatCollection + string.Empty != setting.Value) { setting.Value = updateSettingsDto.AllowStatCollection + string.Empty; @@ -110,12 +142,33 @@ namespace API.Controllers if (!_unitOfWork.HasChanges()) return Ok("Nothing was updated"); - if (!_unitOfWork.HasChanges() || !await _unitOfWork.CommitAsync()) + try { + await _unitOfWork.CommitAsync(); + + if (updateAuthentication) + { + var users = await _unitOfWork.UserRepository.GetNonAdminUsersAsync(); + foreach (var user in users) + { + var errors = await _accountService.ChangeUserPassword(user, AccountService.DefaultPassword); + if (!errors.Any()) continue; + + await _unitOfWork.RollbackAsync(); + return BadRequest(errors); + } + + _logger.LogInformation("Server authentication changed. Updated all non-admins to default password"); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "There was an exception when updating server settings"); await _unitOfWork.RollbackAsync(); return BadRequest("There was a critical issue. Please try again."); } + _logger.LogInformation("Server Settings updated"); _taskScheduler.ScheduleTasks(); return Ok(updateSettingsDto); @@ -148,5 +201,12 @@ namespace API.Controllers var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); return Ok(settingsDto.EnableOpds); } + + [HttpGet("authentication-enabled")] + public async Task> GetAuthenticationEnabled() + { + var settingsDto = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + return Ok(settingsDto.EnableAuthentication); + } } } diff --git a/API/Controllers/UploadController.cs b/API/Controllers/UploadController.cs index 4241a8bc6..43c9b8d09 100644 --- a/API/Controllers/UploadController.cs +++ b/API/Controllers/UploadController.cs @@ -148,7 +148,7 @@ namespace API.Controllers chapter.CoverImage = filePath; chapter.CoverImageLocked = true; _unitOfWork.ChapterRepository.Update(chapter); - var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); } @@ -185,7 +185,7 @@ namespace API.Controllers chapter.CoverImage = string.Empty; chapter.CoverImageLocked = false; _unitOfWork.ChapterRepository.Update(chapter); - var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId); + var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId); volume.CoverImage = chapter.CoverImage; _unitOfWork.VolumeRepository.Update(volume); var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId); diff --git a/API/Controllers/UsersController.cs b/API/Controllers/UsersController.cs index c35e368cc..f5171b819 100644 --- a/API/Controllers/UsersController.cs +++ b/API/Controllers/UsersController.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using API.Data.Repositories; using API.DTOs; using API.Extensions; using API.Interfaces; @@ -38,11 +39,24 @@ namespace API.Controllers return Ok(await _unitOfWork.UserRepository.GetMembersAsync()); } + [AllowAnonymous] + [HttpGet("names")] + public async Task>> GetUserNames() + { + var setting = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); + if (setting.EnableAuthentication) + { + return Unauthorized("This API cannot be used given your server's configuration"); + } + var members = await _unitOfWork.UserRepository.GetMembersAsync(); + return Ok(members.Select(m => m.Username)); + } + [HttpGet("has-reading-progress")] public async Task> HasReadingProgress(int libraryId) { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername()); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId)); } diff --git a/API/DTOs/Account/LoginDto.cs b/API/DTOs/Account/LoginDto.cs index 3da1841bf..a21e9868f 100644 --- a/API/DTOs/Account/LoginDto.cs +++ b/API/DTOs/Account/LoginDto.cs @@ -1,8 +1,8 @@ -namespace API.DTOs +namespace API.DTOs.Account { public class LoginDto { public string Username { get; init; } - public string Password { get; init; } + public string Password { get; set; } } -} \ No newline at end of file +} diff --git a/API/DTOs/LibraryDto.cs b/API/DTOs/LibraryDto.cs index fb08a53e8..9289cfa21 100644 --- a/API/DTOs/LibraryDto.cs +++ b/API/DTOs/LibraryDto.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using API.Entities.Enums; namespace API.DTOs @@ -7,8 +8,11 @@ namespace API.DTOs { public int Id { get; init; } public string Name { get; init; } - public string CoverImage { get; init; } + /// + /// Last time Library was scanned + /// + public DateTime LastScanned { get; init; } public LibraryType Type { get; init; } public ICollection Folders { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/OPDS/FeedLink.cs b/API/DTOs/OPDS/FeedLink.cs index 1dc3d5b1e..1589109ad 100644 --- a/API/DTOs/OPDS/FeedLink.cs +++ b/API/DTOs/OPDS/FeedLink.cs @@ -23,7 +23,7 @@ namespace API.DTOs.OPDS public string Title { get; set; } [XmlAttribute("count", Namespace = "http://vaemendis.net/opds-pse/ns")] - public int TotalPages { get; set; } = 0; + public int TotalPages { get; set; } public bool ShouldSerializeTotalPages() { diff --git a/API/DTOs/Reader/IChapterInfoDto.cs b/API/DTOs/Reader/IChapterInfoDto.cs index 63b5c9a62..67aa6caf6 100644 --- a/API/DTOs/Reader/IChapterInfoDto.cs +++ b/API/DTOs/Reader/IChapterInfoDto.cs @@ -1,5 +1,4 @@ using API.Entities.Enums; -using Newtonsoft.Json; namespace API.DTOs.Reader { diff --git a/API/DTOs/RegisterDto.cs b/API/DTOs/RegisterDto.cs index d04c2a03e..1bf598f5d 100644 --- a/API/DTOs/RegisterDto.cs +++ b/API/DTOs/RegisterDto.cs @@ -8,7 +8,7 @@ namespace API.DTOs public string Username { get; init; } [Required] [StringLength(32, MinimumLength = 6)] - public string Password { get; init; } + public string Password { get; set; } public bool IsAdmin { get; init; } } -} \ No newline at end of file +} diff --git a/API/DTOs/Settings/ServerSettingDTO.cs b/API/DTOs/Settings/ServerSettingDTO.cs index 271f7d7a6..aace57127 100644 --- a/API/DTOs/Settings/ServerSettingDTO.cs +++ b/API/DTOs/Settings/ServerSettingDTO.cs @@ -1,4 +1,4 @@ -namespace API.DTOs +namespace API.DTOs.Settings { public class ServerSettingDto { @@ -21,5 +21,14 @@ /// Enables OPDS connections to be made to the server. /// public bool EnableOpds { get; set; } + + /// + /// Enables Authentication on the server. Defaults to true. + /// + public bool EnableAuthentication { get; set; } + /// + /// Base Url for the kavita. Requires restart to take effect. + /// + public string BaseUrl { get; set; } } } diff --git a/API/Data/Metadata/ComicInfo.cs b/API/Data/Metadata/ComicInfo.cs new file mode 100644 index 000000000..9f846ea42 --- /dev/null +++ b/API/Data/Metadata/ComicInfo.cs @@ -0,0 +1,51 @@ +namespace API.Data.Metadata +{ + /// + /// A representation of a ComicInfo.xml file + /// + /// See reference of the loose spec here: https://github.com/Kussie/ComicInfoStandard/blob/main/ComicInfo.xsd + public class ComicInfo + { + public string Summary { get; set; } + public string Title { get; set; } + public string Series { get; set; } + public string Number { get; set; } + public string Volume { get; set; } + public string Notes { get; set; } + public string Genre { get; set; } + public int PageCount { get; set; } + // ReSharper disable once InconsistentNaming + public string LanguageISO { get; set; } + public string Web { get; set; } + public int Month { get; set; } + public int Year { get; set; } + /// + /// Rating based on the content. Think PG-13, R for movies + /// + public string AgeRating { get; set; } + /// + /// User's rating of the content + /// + public float UserRating { get; set; } + + public string AlternateSeries { get; set; } + public string StoryArc { get; set; } + public string SeriesGroup { get; set; } + public string AlternativeSeries { get; set; } + public string AlternativeNumber { get; set; } + + + /// + /// This is the Author. For Books, we map creator tag in OPF to this field. Comma separated if multiple. + /// + public string Writer { get; set; } // TODO: Validate if we should make this a list of writers + public string Penciller { get; set; } + public string Inker { get; set; } + public string Colorist { get; set; } + public string Letterer { get; set; } + public string CoverArtist { get; set; } + public string Editor { get; set; } + public string Publisher { get; set; } + + } +} diff --git a/API/Data/Migrations/20211001113608_LastScannedLibrary.Designer.cs b/API/Data/Migrations/20211001113608_LastScannedLibrary.Designer.cs new file mode 100644 index 000000000..ad28c5839 --- /dev/null +++ b/API/Data/Migrations/20211001113608_LastScannedLibrary.Designer.cs @@ -0,0 +1,1045 @@ +// +using System; +using API.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace API.Data.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20211001113608_LastScannedLibrary")] + partial class LastScannedLibrary + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "5.0.8"); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BookReaderDarkMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("SiteDarkMode") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("API.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format") + .IsUnique(); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("API.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens"); + }); + + modelBuilder.Entity("API.Entities.AppUserBookmark", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserPreferences", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("API.Entities.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserProgress", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRating", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.AppUserRole", b => + { + b.HasOne("API.Entities.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.HasOne("API.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.FolderPath", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.MangaFile", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.HasOne("API.Entities.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("API.Entities.ReadingListItem", b => + { + b.HasOne("API.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.HasOne("API.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("API.Entities.SeriesMetadata", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("API.Entities.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.HasOne("API.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("API.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("API.Entities.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("API.Entities.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("API.Entities.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("API.Entities.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.AppUser", b => + { + b.Navigation("Bookmarks"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingLists"); + + b.Navigation("UserPreferences"); + + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("API.Entities.Chapter", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("API.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("API.Entities.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("API.Entities.Series", b => + { + b.Navigation("Metadata"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("API.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/API/Data/Migrations/20211001113608_LastScannedLibrary.cs b/API/Data/Migrations/20211001113608_LastScannedLibrary.cs new file mode 100644 index 000000000..eb1fdc5cb --- /dev/null +++ b/API/Data/Migrations/20211001113608_LastScannedLibrary.cs @@ -0,0 +1,25 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +namespace API.Data.Migrations +{ + public partial class LastScannedLibrary : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastScanned", + table: "Library", + type: "TEXT", + nullable: false, + defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified)); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastScanned", + table: "Library"); + } + } +} diff --git a/API/Data/Migrations/DataContextModelSnapshot.cs b/API/Data/Migrations/DataContextModelSnapshot.cs index 38a09633e..21a9d930a 100644 --- a/API/Data/Migrations/DataContextModelSnapshot.cs +++ b/API/Data/Migrations/DataContextModelSnapshot.cs @@ -397,6 +397,9 @@ namespace API.Data.Migrations b.Property("LastModified") .HasColumnType("TEXT"); + b.Property("LastScanned") + .HasColumnType("TEXT"); + b.Property("Name") .HasColumnType("TEXT"); diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index f1905eaa8..54c808d9c 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -1,7 +1,5 @@ using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Text; using System.Threading.Tasks; using API.DTOs; using API.DTOs.Reader; diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 7f3544aee..caae93dd6 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using API.DTOs; @@ -11,6 +12,17 @@ using Microsoft.EntityFrameworkCore; namespace API.Data.Repositories { + + [Flags] + public enum LibraryIncludes + { + None = 1, + Series = 2, + AppUser = 4, + Folders = 8, + // Ratings = 16 + } + public class LibraryRepository : ILibraryRepository { private readonly DataContext _context; @@ -58,7 +70,7 @@ namespace API.Data.Repositories public async Task DeleteLibrary(int libraryId) { - var library = await GetLibraryForIdAsync(libraryId); + var library = await GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders | LibraryIncludes.Series); _context.Library.Remove(library); return await _context.SaveChangesAsync() > 0; } @@ -91,14 +103,37 @@ namespace API.Data.Repositories .ToListAsync(); } - public async Task GetLibraryForIdAsync(int libraryId) + public async Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes) { - return await _context.Library - .Where(x => x.Id == libraryId) - .Include(f => f.Folders) - .Include(l => l.Series) - .SingleAsync(); + + var query = _context.Library + .Where(x => x.Id == libraryId); + + query = AddIncludesToQuery(query, includes); + return await query.SingleAsync(); } + + private static IQueryable AddIncludesToQuery(IQueryable query, LibraryIncludes includeFlags) + { + if (includeFlags.HasFlag(LibraryIncludes.Folders)) + { + query = query.Include(l => l.Folders); + } + + if (includeFlags.HasFlag(LibraryIncludes.Series)) + { + query = query.Include(l => l.Series); + } + + if (includeFlags.HasFlag(LibraryIncludes.AppUser)) + { + query = query.Include(l => l.AppUsers); + } + + return query; + } + + /// /// This returns a Library with all it's Series -> Volumes -> Chapters. This is expensive. Should only be called when needed. /// @@ -106,7 +141,6 @@ namespace API.Data.Repositories /// public async Task GetFullLibraryForIdAsync(int libraryId) { - return await _context.Library .Where(x => x.Id == libraryId) .Include(f => f.Folders) diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 4f44bc943..fc9199ccb 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -53,7 +53,7 @@ namespace API.Data.Repositories { return await _context.ReadingList .Where(r => r.Id == readingListId) - .Include(r => r.Items) + .Include(r => r.Items.OrderBy(item => item.Order)) .SingleOrDefaultAsync(); } diff --git a/API/Data/Repositories/SeriesRepository.cs b/API/Data/Repositories/SeriesRepository.cs index 3ed415859..67cd83276 100644 --- a/API/Data/Repositories/SeriesRepository.cs +++ b/API/Data/Repositories/SeriesRepository.cs @@ -1,15 +1,15 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; -using API.Comparators; +using API.Data.Scanner; using API.DTOs; using API.DTOs.Filtering; using API.Entities; using API.Extensions; using API.Helpers; using API.Interfaces.Repositories; +using API.Services.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; using Microsoft.EntityFrameworkCore; @@ -26,9 +26,9 @@ namespace API.Data.Repositories _mapper = mapper; } - public void Add(Series series) + public void Attach(Series series) { - _context.Series.Add(series); + _context.Series.Attach(series); } public void Update(Series series) @@ -36,19 +36,9 @@ namespace API.Data.Repositories _context.Entry(series).State = EntityState.Modified; } - public async Task SaveAllAsync() + public void Remove(Series series) { - return await _context.SaveChangesAsync() > 0; - } - - public bool SaveAll() - { - return _context.SaveChanges() > 0; - } - - public async Task GetSeriesByNameAsync(string name) - { - return await _context.Series.SingleOrDefaultAsync(x => x.Name == name); + _context.Series.Remove(series); } public async Task DoesSeriesNameExistInLibrary(string name) @@ -64,11 +54,6 @@ namespace API.Data.Repositories .CountAsync() > 1; } - public Series GetSeriesByName(string name) - { - return _context.Series.SingleOrDefault(x => x.Name == name); - } - public async Task> GetSeriesForLibraryIdAsync(int libraryId) { return await _context.Series @@ -77,6 +62,43 @@ namespace API.Data.Repositories .ToListAsync(); } + /// + /// Used for to + /// + /// + /// + public async Task> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams) + { + var query = _context.Series + .Where(s => s.LibraryId == libraryId) + .Include(s => s.Metadata) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .AsSplitQuery() + .OrderBy(s => s.SortName); + + return await PagedList.CreateAsync(query, userParams.PageNumber, userParams.PageSize); + } + + /// + /// This is a heavy call. Returns all entities down to Files and Library and Series Metadata. + /// + /// + /// + public async Task GetFullSeriesForSeriesIdAsync(int seriesId) + { + return await _context.Series + .Where(s => s.Id == seriesId) + .Include(s => s.Metadata) + .Include(s => s.Library) + .Include(s => s.Volumes) + .ThenInclude(v => v.Chapters) + .ThenInclude(c => c.Files) + .AsSplitQuery() + .SingleOrDefaultAsync(); + } + public async Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter) { var formats = filter.GetSqlFilter(); @@ -103,41 +125,12 @@ namespace API.Data.Repositories .ToListAsync(); } - public async Task> GetVolumesDtoAsync(int seriesId, int userId) - { - var volumes = await _context.Volume - .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Chapters) - .OrderBy(volume => volume.Number) - .ProjectTo(_mapper.ConfigurationProvider) - .AsNoTracking() - .ToListAsync(); - - await AddVolumeModifiers(userId, volumes); - SortSpecialChapters(volumes); - - return volumes; - } - - private static void SortSpecialChapters(IEnumerable volumes) - { - var sorter = new NaturalSortComparer(); - foreach (var v in volumes.Where(vDto => vDto.Number == 0)) - { - v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList(); - } - } - public async Task> GetVolumes(int seriesId) - { - return await _context.Volume - .Where(vol => vol.SeriesId == seriesId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) - .OrderBy(vol => vol.Number) - .ToListAsync(); - } + + + + public async Task GetSeriesDtoByIdAsync(int seriesId, int userId) { @@ -151,55 +144,8 @@ namespace API.Data.Repositories return seriesList[0]; } - public async Task GetVolumeAsync(int volumeId) - { - return await _context.Volume - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) - .SingleOrDefaultAsync(vol => vol.Id == volumeId); - } - public async Task GetVolumeDtoAsync(int volumeId) - { - return await _context.Volume - .Where(vol => vol.Id == volumeId) - .AsNoTracking() - .ProjectTo(_mapper.ConfigurationProvider) - .SingleAsync(); - } - - public async Task GetVolumeDtoAsync(int volumeId, int userId) - { - var volume = await _context.Volume - .Where(vol => vol.Id == volumeId) - .Include(vol => vol.Chapters) - .ThenInclude(c => c.Files) - .ProjectTo(_mapper.ConfigurationProvider) - .SingleAsync(vol => vol.Id == volumeId); - - var volumeList = new List() {volume}; - await AddVolumeModifiers(userId, volumeList); - - return volumeList[0]; - } - - /// - /// Returns all volumes that contain a seriesId in passed array. - /// - /// - /// - public async Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false) - { - var query = _context.Volume - .Where(v => seriesIds.Contains(v.SeriesId)); - - if (includeChapters) - { - query = query.Include(v => v.Chapters); - } - return await query.ToListAsync(); - } public async Task DeleteSeriesAsync(int seriesId) { @@ -209,11 +155,12 @@ namespace API.Data.Repositories return await _context.SaveChangesAsync() > 0; } - public async Task GetVolumeByIdAsync(int volumeId) - { - return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); - } + /// + /// Returns Volumes, Metadata, and Collection Tags + /// + /// + /// public async Task GetSeriesByIdAsync(int seriesId) { return await _context.Series @@ -244,7 +191,7 @@ namespace API.Data.Repositories } /// - /// This returns a list of tuples back for each series id passed + /// This returns a dictonary mapping seriesId -> list of chapters back for each series id passed /// /// /// @@ -301,24 +248,7 @@ namespace API.Data.Repositories .SingleOrDefaultAsync(); } - private async Task AddVolumeModifiers(int userId, IReadOnlyCollection volumes) - { - var volIds = volumes.Select(s => s.Id); - var userProgress = await _context.AppUserProgresses - .Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId)) - .AsNoTracking() - .ToListAsync(); - foreach (var v in volumes) - { - foreach (var c in v.Chapters) - { - c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); - } - - v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); - } - } /// /// Returns a list of Series that were added, ordered by Created desc @@ -497,5 +427,63 @@ namespace API.Data.Repositories .AsNoTracking() .ToListAsync(); } + + /// + /// Returns the number of series for a given library (or all libraries if libraryId is 0) + /// + /// Defaults to 0, library to restrict count to + /// + private async Task GetSeriesCount(int libraryId = 0) + { + if (libraryId > 0) + { + return await _context.Series + .Where(s => s.LibraryId == libraryId) + .CountAsync(); + } + return await _context.Series.CountAsync(); + } + + /// + /// Returns the number of series that should be processed in parallel to optimize speed and memory. Minimum of 50 + /// + /// Defaults to 0 meaning no library + /// + private async Task> GetChunkSize(int libraryId = 0) + { + // TODO: Think about making this bigger depending on number of files a user has in said library + // and number of cores and amount of memory. We can then make an optimal choice + var totalSeries = await GetSeriesCount(libraryId); + var procCount = Math.Max(Environment.ProcessorCount - 1, 1); + + if (totalSeries < procCount * 2 || totalSeries < 50) + { + return new Tuple(totalSeries, totalSeries); + } + + + return new Tuple(totalSeries, Math.Max(totalSeries / procCount, 50)); + } + + public async Task GetChunkInfo(int libraryId = 0) + { + var (totalSeries, chunkSize) = await GetChunkSize(libraryId); + + if (totalSeries == 0) return new Chunk() + { + TotalChunks = 0, + TotalSize = 0, + ChunkSize = 0 + }; + + var totalChunks = Math.Max((int) Math.Ceiling((totalSeries * 1.0) / chunkSize), 1); + + return new Chunk() + { + TotalSize = totalSeries, + ChunkSize = chunkSize, + TotalChunks = totalChunks + }; + } } } diff --git a/API/Data/Repositories/SettingsRepository.cs b/API/Data/Repositories/SettingsRepository.cs index 1eb0165bb..168b5a21e 100644 --- a/API/Data/Repositories/SettingsRepository.cs +++ b/API/Data/Repositories/SettingsRepository.cs @@ -1,7 +1,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using API.DTOs; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using API.Interfaces.Repositories; @@ -35,6 +35,15 @@ namespace API.Data.Repositories return _mapper.Map(settings); } + public ServerSettingDto GetSettingsDto() + { + var settings = _context.ServerSetting + .Select(x => x) + .AsNoTracking() + .ToList(); + return _mapper.Map(settings); + } + public Task GetSettingAsync(ServerSettingKey key) { return _context.ServerSetting.SingleOrDefaultAsync(x => x.Key == key); diff --git a/API/Data/Repositories/UserRepository.cs b/API/Data/Repositories/UserRepository.cs index 4e20039c7..ece1356fd 100644 --- a/API/Data/Repositories/UserRepository.cs +++ b/API/Data/Repositories/UserRepository.cs @@ -153,6 +153,16 @@ namespace API.Data.Repositories return await _userManager.GetUsersInRoleAsync(PolicyConstants.AdminRole); } + public async Task> GetNonAdminUsersAsync() + { + return await _userManager.GetUsersInRoleAsync(PolicyConstants.PlebRole); + } + + public async Task IsUserAdmin(AppUser user) + { + return await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole); + } + public async Task GetUserRating(int seriesId, int userId) { return await _context.AppUserRating.Where(r => r.SeriesId == seriesId && r.AppUserId == userId) @@ -237,8 +247,8 @@ namespace API.Data.Repositories Libraries = u.Libraries.Select(l => new LibraryDto { Name = l.Name, - CoverImage = l.CoverImage, Type = l.Type, + LastScanned = l.LastScanned, Folders = l.Folders.Select(x => x.Path).ToList() }).ToList() }) diff --git a/API/Data/Repositories/VolumeRepository.cs b/API/Data/Repositories/VolumeRepository.cs index d991a928c..339da798d 100644 --- a/API/Data/Repositories/VolumeRepository.cs +++ b/API/Data/Repositories/VolumeRepository.cs @@ -1,9 +1,8 @@ using System.Collections.Generic; -using System.IO; using System.Linq; using System.Threading.Tasks; +using API.Comparators; using API.DTOs; -using API.DTOs.Reader; using API.Entities; using API.Interfaces.Repositories; using AutoMapper; @@ -15,10 +14,17 @@ namespace API.Data.Repositories public class VolumeRepository : IVolumeRepository { private readonly DataContext _context; + private readonly IMapper _mapper; - public VolumeRepository(DataContext context) + public VolumeRepository(DataContext context, IMapper mapper) { _context = context; + _mapper = mapper; + } + + public void Add(Volume volume) + { + _context.Volume.Add(volume); } public void Update(Volume volume) @@ -26,6 +32,16 @@ namespace API.Data.Repositories _context.Entry(volume).State = EntityState.Modified; } + public void Remove(Volume volume) + { + _context.Volume.Remove(volume); + } + + /// + /// Returns a list of non-tracked files for a given volume. + /// + /// + /// public async Task> GetFilesForVolume(int volumeId) { return await _context.Chapter @@ -36,6 +52,11 @@ namespace API.Data.Repositories .ToListAsync(); } + /// + /// Returns the cover image file for the given volume + /// + /// + /// public async Task GetVolumeCoverImageAsync(int volumeId) { return await _context.Volume @@ -45,6 +66,11 @@ namespace API.Data.Repositories .SingleOrDefaultAsync(); } + /// + /// Returns all chapter Ids belonging to a list of Volume Ids + /// + /// + /// public async Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds) { return await _context.Chapter @@ -52,5 +78,131 @@ namespace API.Data.Repositories .Select(c => c.Id) .ToListAsync(); } + + /// + /// Returns all volumes that contain a seriesId in passed array. + /// + /// + /// + public async Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false) + { + var query = _context.Volume + .Where(v => seriesIds.Contains(v.SeriesId)); + + if (includeChapters) + { + query = query.Include(v => v.Chapters); + } + return await query.ToListAsync(); + } + + /// + /// Returns an individual Volume including Chapters and Files and Reading Progress for a given volumeId + /// + /// + /// + /// + public async Task GetVolumeDtoAsync(int volumeId, int userId) + { + var volume = await _context.Volume + .Where(vol => vol.Id == volumeId) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) + .ProjectTo(_mapper.ConfigurationProvider) + .SingleAsync(vol => vol.Id == volumeId); + + var volumeList = new List() {volume}; + await AddVolumeModifiers(userId, volumeList); + + return volumeList[0]; + } + + /// + /// Returns the full Volumes including Chapters and Files for a given series + /// + /// + /// + public async Task> GetVolumes(int seriesId) + { + return await _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) + .OrderBy(vol => vol.Number) + .ToListAsync(); + } + + /// + /// Returns a single volume with Chapter and Files + /// + /// + /// + public async Task GetVolumeAsync(int volumeId) + { + return await _context.Volume + .Include(vol => vol.Chapters) + .ThenInclude(c => c.Files) + .SingleOrDefaultAsync(vol => vol.Id == volumeId); + } + + + /// + /// Returns all volumes for a given series with progress information attached. Includes all Chapters as well. + /// + /// + /// + /// + public async Task> GetVolumesDtoAsync(int seriesId, int userId) + { + var volumes = await _context.Volume + .Where(vol => vol.SeriesId == seriesId) + .Include(vol => vol.Chapters) + .OrderBy(volume => volume.Number) + .ProjectTo(_mapper.ConfigurationProvider) + .AsNoTracking() + .ToListAsync(); + + await AddVolumeModifiers(userId, volumes); + SortSpecialChapters(volumes); + + return volumes; + } + + public async Task GetVolumeByIdAsync(int volumeId) + { + return await _context.Volume.SingleOrDefaultAsync(x => x.Id == volumeId); + } + + + private static void SortSpecialChapters(IEnumerable volumes) + { + var sorter = new NaturalSortComparer(); + foreach (var v in volumes.Where(vDto => vDto.Number == 0)) + { + v.Chapters = v.Chapters.OrderBy(x => x.Range, sorter).ToList(); + } + } + + + private async Task AddVolumeModifiers(int userId, IReadOnlyCollection volumes) + { + var volIds = volumes.Select(s => s.Id); + var userProgress = await _context.AppUserProgresses + .Where(p => p.AppUserId == userId && volIds.Contains(p.VolumeId)) + .AsNoTracking() + .ToListAsync(); + + foreach (var v in volumes) + { + foreach (var c in v.Chapters) + { + c.PagesRead = userProgress.Where(p => p.ChapterId == c.Id).Sum(p => p.PagesRead); + } + + v.PagesRead = userProgress.Where(p => p.VolumeId == v.Id).Sum(p => p.PagesRead); + } + } + + } } diff --git a/API/Data/Scanner/Chunk.cs b/API/Data/Scanner/Chunk.cs new file mode 100644 index 000000000..9a9e04f5c --- /dev/null +++ b/API/Data/Scanner/Chunk.cs @@ -0,0 +1,21 @@ +namespace API.Data.Scanner +{ + /// + /// Represents a set of Entities which is broken up and iterated on + /// + public class Chunk + { + /// + /// Total number of entities + /// + public int TotalSize { get; set; } + /// + /// Size of each chunk to iterate over + /// + public int ChunkSize { get; set; } + /// + /// Total chunks to iterate over + /// + public int TotalChunks { get; set; } + } +} diff --git a/API/Data/Seed.cs b/API/Data/Seed.cs index f264168ae..6b62089d0 100644 --- a/API/Data/Seed.cs +++ b/API/Data/Seed.cs @@ -49,6 +49,8 @@ namespace API.Data new () {Key = ServerSettingKey.Port, Value = "5000"}, // Not used from DB, but DB is sync with appSettings.json new () {Key = ServerSettingKey.AllowStatCollection, Value = "true"}, new () {Key = ServerSettingKey.EnableOpds, Value = "false"}, + new () {Key = ServerSettingKey.EnableAuthentication, Value = "true"}, + new () {Key = ServerSettingKey.BaseUrl, Value = "/"}, }; foreach (var defaultSetting in defaultSettings) diff --git a/API/Entities/AppUserProgress.cs b/API/Entities/AppUserProgress.cs index 08fffa540..b3e0a5dfd 100644 --- a/API/Entities/AppUserProgress.cs +++ b/API/Entities/AppUserProgress.cs @@ -1,6 +1,5 @@  using System; -using System.ComponentModel.DataAnnotations; using API.Entities.Interfaces; namespace API.Entities diff --git a/API/Entities/Enums/ServerSettingKey.cs b/API/Entities/Enums/ServerSettingKey.cs index fab4a7cba..997d0a33e 100644 --- a/API/Entities/Enums/ServerSettingKey.cs +++ b/API/Entities/Enums/ServerSettingKey.cs @@ -20,6 +20,10 @@ namespace API.Entities.Enums AllowStatCollection = 6, [Description("EnableOpds")] EnableOpds = 7, + [Description("EnableAuthentication")] + EnableAuthentication = 8, + [Description("BaseUrl")] + BaseUrl = 9 } } diff --git a/API/Entities/FolderPath.cs b/API/Entities/FolderPath.cs index dab3d86cd..267564fe8 100644 --- a/API/Entities/FolderPath.cs +++ b/API/Entities/FolderPath.cs @@ -8,12 +8,12 @@ namespace API.Entities public int Id { get; set; } public string Path { get; set; } /// - /// Used when scanning to see if we can skip if nothing has changed. + /// Used when scanning to see if we can skip if nothing has changed. (not implemented) /// public DateTime LastScanned { get; set; } - + // Relationship public Library Library { get; set; } public int LibraryId { get; set; } } -} \ No newline at end of file +} diff --git a/API/Entities/Library.cs b/API/Entities/Library.cs index faf95e149..c77fb68dd 100644 --- a/API/Entities/Library.cs +++ b/API/Entities/Library.cs @@ -13,9 +13,13 @@ namespace API.Entities public LibraryType Type { get; set; } public DateTime Created { get; set; } public DateTime LastModified { get; set; } + /// + /// Last time Library was scanned + /// + public DateTime LastScanned { get; set; } public ICollection Folders { get; set; } public ICollection AppUsers { get; set; } public ICollection Series { get; set; } - + } -} \ No newline at end of file +} diff --git a/API/Entities/MangaFile.cs b/API/Entities/MangaFile.cs index 72c620ce9..2865178c7 100644 --- a/API/Entities/MangaFile.cs +++ b/API/Entities/MangaFile.cs @@ -38,5 +38,13 @@ namespace API.Entities { return File.GetLastWriteTime(FilePath) > LastModified; } + + /// + /// Updates the Last Modified time of the underlying file + /// + public void UpdateLastModified() + { + LastModified = File.GetLastWriteTime(FilePath); + } } } diff --git a/API/Entities/Series.cs b/API/Entities/Series.cs index 899e52bfd..5b7bc86bd 100644 --- a/API/Entities/Series.cs +++ b/API/Entities/Series.cs @@ -33,7 +33,7 @@ namespace API.Entities /// /// Summary information related to the Series /// - public string Summary { get; set; } // TODO: Migrate into SeriesMetdata (with Metadata update) + public string Summary { get; set; } // NOTE: Migrate into SeriesMetdata (with Metadata update) public DateTime Created { get; set; } public DateTime LastModified { get; set; } /// diff --git a/API/Entities/Volume.cs b/API/Entities/Volume.cs index 3be7a4d6a..f4f0076db 100644 --- a/API/Entities/Volume.cs +++ b/API/Entities/Volume.cs @@ -8,6 +8,9 @@ namespace API.Entities public class Volume : IEntityDate { public int Id { get; set; } + /// + /// A String representation of the volume number. Allows for floats + /// public string Name { get; set; } public int Number { get; set; } public IList Chapters { get; set; } diff --git a/API/Extensions/ApplicationServiceExtensions.cs b/API/Extensions/ApplicationServiceExtensions.cs index c08555c69..cd5a621fc 100644 --- a/API/Extensions/ApplicationServiceExtensions.cs +++ b/API/Extensions/ApplicationServiceExtensions.cs @@ -36,12 +36,13 @@ namespace API.Extensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddSqLite(config, env); services.AddLogging(config); - services.AddSignalR(); + services.AddSignalR(opt => opt.EnableDetailedErrors = true); } private static void AddSqLite(this IServiceCollection services, IConfiguration config, diff --git a/API/Extensions/DirectoryInfoExtensions.cs b/API/Extensions/DirectoryInfoExtensions.cs index 892c690b3..b92901046 100644 --- a/API/Extensions/DirectoryInfoExtensions.cs +++ b/API/Extensions/DirectoryInfoExtensions.cs @@ -76,7 +76,8 @@ namespace API.Extensions directoryIndex++; } - foreach (var subDirectory in directory.EnumerateDirectories()) + var sort = new NaturalSortComparer(); + foreach (var subDirectory in directory.EnumerateDirectories().OrderBy(d => d.FullName, sort)) { FlattenDirectory(root, subDirectory, ref directoryIndex); } diff --git a/API/Extensions/HttpExtensions.cs b/API/Extensions/HttpExtensions.cs index 80b52f18f..975cbde5f 100644 --- a/API/Extensions/HttpExtensions.cs +++ b/API/Extensions/HttpExtensions.cs @@ -1,4 +1,5 @@ -using System.Linq; +using System.IO; +using System.Linq; using System.Text; using System.Text.Json; using API.Helpers; @@ -41,8 +42,9 @@ namespace API.Extensions public static void AddCacheHeader(this HttpResponse response, string filename) { if (filename == null || filename.Length <= 0) return; + var hashContent = filename + File.GetLastWriteTimeUtc(filename); using var sha1 = new System.Security.Cryptography.SHA256CryptoServiceProvider(); - response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(filename)).Select(x => x.ToString("X2")))); + response.Headers.Add("ETag", string.Concat(sha1.ComputeHash(Encoding.UTF8.GetBytes(hashContent)).Select(x => x.ToString("X2")))); } } diff --git a/API/Helpers/AutoMapperProfiles.cs b/API/Helpers/AutoMapperProfiles.cs index 03445ccb2..ff1a30e34 100644 --- a/API/Helpers/AutoMapperProfiles.cs +++ b/API/Helpers/AutoMapperProfiles.cs @@ -3,6 +3,7 @@ using System.Linq; using API.DTOs; using API.DTOs.Reader; using API.DTOs.ReadingLists; +using API.DTOs.Settings; using API.Entities; using API.Helpers.Converters; using AutoMapper; diff --git a/API/Helpers/Converters/ServerSettingConverter.cs b/API/Helpers/Converters/ServerSettingConverter.cs index dbd13ab9e..86ed6235e 100644 --- a/API/Helpers/Converters/ServerSettingConverter.cs +++ b/API/Helpers/Converters/ServerSettingConverter.cs @@ -1,5 +1,5 @@ using System.Collections.Generic; -using API.DTOs; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; using AutoMapper; @@ -36,6 +36,12 @@ namespace API.Helpers.Converters case ServerSettingKey.EnableOpds: destination.EnableOpds = bool.Parse(row.Value); break; + case ServerSettingKey.EnableAuthentication: + destination.EnableAuthentication = bool.Parse(row.Value); + break; + case ServerSettingKey.BaseUrl: + destination.BaseUrl = row.Value; + break; } } diff --git a/API/Interfaces/Repositories/ILibraryRepository.cs b/API/Interfaces/Repositories/ILibraryRepository.cs index 4d9b03fe4..1ba6ac910 100644 --- a/API/Interfaces/Repositories/ILibraryRepository.cs +++ b/API/Interfaces/Repositories/ILibraryRepository.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Data.Repositories; using API.DTOs; using API.Entities; using API.Entities.Enums; @@ -13,7 +14,7 @@ namespace API.Interfaces.Repositories void Delete(Library library); Task> GetLibraryDtosAsync(); Task LibraryExists(string libraryName); - Task GetLibraryForIdAsync(int libraryId); + Task GetLibraryForIdAsync(int libraryId, LibraryIncludes includes); Task GetFullLibraryForIdAsync(int libraryId); Task GetFullLibraryForIdAsync(int libraryId, int seriesId); Task> GetLibraryDtosForUsernameAsync(string userName); diff --git a/API/Interfaces/Repositories/ISeriesRepository.cs b/API/Interfaces/Repositories/ISeriesRepository.cs index 05fe937eb..0b3ed8eeb 100644 --- a/API/Interfaces/Repositories/ISeriesRepository.cs +++ b/API/Interfaces/Repositories/ISeriesRepository.cs @@ -1,7 +1,6 @@ -using System; -using System.Collections; -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; +using API.Data.Scanner; using API.DTOs; using API.DTOs.Filtering; using API.Entities; @@ -11,12 +10,10 @@ namespace API.Interfaces.Repositories { public interface ISeriesRepository { - void Add(Series series); + void Attach(Series series); void Update(Series series); - Task GetSeriesByNameAsync(string name); + void Remove(Series series); Task DoesSeriesNameExistInLibrary(string name); - Series GetSeriesByName(string name); - /// /// Adds user information like progress, ratings, etc /// @@ -25,7 +22,6 @@ namespace API.Interfaces.Repositories /// /// Task> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter); - /// /// Does not add user information like progress, ratings, etc. /// @@ -34,20 +30,8 @@ namespace API.Interfaces.Repositories /// Task> SearchSeries(int[] libraryIds, string searchQuery); Task> GetSeriesForLibraryIdAsync(int libraryId); - Task> GetVolumesDtoAsync(int seriesId, int userId); - Task> GetVolumes(int seriesId); Task GetSeriesDtoByIdAsync(int seriesId, int userId); - Task GetVolumeAsync(int volumeId); - Task GetVolumeDtoAsync(int volumeId, int userId); - /// - /// A fast lookup of just the volume information with no tracking. - /// - /// - /// - Task GetVolumeDtoAsync(int volumeId); - Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); Task DeleteSeriesAsync(int seriesId); - Task GetVolumeByIdAsync(int volumeId); Task GetSeriesByIdAsync(int seriesId); Task GetChapterIdsForSeriesAsync(int[] seriesIds); Task>> GetChapterIdWithSeriesIdForSeriesAsync(int[] seriesIds); @@ -58,16 +42,17 @@ namespace API.Interfaces.Repositories /// /// Task AddSeriesModifiers(int userId, List series); - - Task GetSeriesCoverImageAsync(int seriesId); Task> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter); - Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); + Task> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter); // NOTE: Probably put this in LibraryRepo Task GetSeriesMetadata(int seriesId); Task> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams); Task> GetFilesForSeries(int seriesId); Task> GetSeriesDtoForIdsAsync(IEnumerable seriesIds, int userId); Task> GetAllCoverImagesAsync(); Task> GetLockedCoverImagesAsync(); + Task> GetFullSeriesForLibraryIdAsync(int libraryId, UserParams userParams); + Task GetFullSeriesForSeriesIdAsync(int seriesId); + Task GetChunkInfo(int libraryId = 0); } } diff --git a/API/Interfaces/Repositories/ISettingsRepository.cs b/API/Interfaces/Repositories/ISettingsRepository.cs index f1687743d..79014dce4 100644 --- a/API/Interfaces/Repositories/ISettingsRepository.cs +++ b/API/Interfaces/Repositories/ISettingsRepository.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; -using API.DTOs; +using API.DTOs.Settings; using API.Entities; using API.Entities.Enums; @@ -10,6 +10,7 @@ namespace API.Interfaces.Repositories { void Update(ServerSetting settings); Task GetSettingsDtoAsync(); + ServerSettingDto GetSettingsDto(); Task GetSettingAsync(ServerSettingKey key); Task> GetSettingsAsync(); diff --git a/API/Interfaces/Repositories/IUserRepository.cs b/API/Interfaces/Repositories/IUserRepository.cs index 22bd9dc92..65d943623 100644 --- a/API/Interfaces/Repositories/IUserRepository.cs +++ b/API/Interfaces/Repositories/IUserRepository.cs @@ -15,6 +15,8 @@ namespace API.Interfaces.Repositories public void Delete(AppUser user); Task> GetMembersAsync(); Task> GetAdminUsersAsync(); + Task> GetNonAdminUsersAsync(); + Task IsUserAdmin(AppUser user); Task GetUserRating(int seriesId, int userId); Task GetPreferencesAsync(string username); Task> GetBookmarkDtosForSeries(int userId, int seriesId); diff --git a/API/Interfaces/Repositories/IVolumeRepository.cs b/API/Interfaces/Repositories/IVolumeRepository.cs index 62ec0ef9a..63045a38d 100644 --- a/API/Interfaces/Repositories/IVolumeRepository.cs +++ b/API/Interfaces/Repositories/IVolumeRepository.cs @@ -7,9 +7,19 @@ namespace API.Interfaces.Repositories { public interface IVolumeRepository { + void Add(Volume volume); void Update(Volume volume); + void Remove(Volume volume); Task> GetFilesForVolume(int volumeId); Task GetVolumeCoverImageAsync(int volumeId); Task> GetChapterIdsByVolumeIds(IReadOnlyList volumeIds); + + // From Series Repo + Task> GetVolumesDtoAsync(int seriesId, int userId); + Task GetVolumeAsync(int volumeId); + Task GetVolumeDtoAsync(int volumeId, int userId); + Task> GetVolumesForSeriesAsync(IList seriesIds, bool includeChapters = false); + Task> GetVolumes(int seriesId); + Task GetVolumeByIdAsync(int volumeId); } } diff --git a/API/Interfaces/Services/IAccountService.cs b/API/Interfaces/Services/IAccountService.cs new file mode 100644 index 000000000..e07ce2f79 --- /dev/null +++ b/API/Interfaces/Services/IAccountService.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using API.Entities; +using API.Errors; + +namespace API.Interfaces.Services +{ + public interface IAccountService + { + Task> ChangeUserPassword(AppUser user, string newPassword); + } +} diff --git a/API/Interfaces/Services/IArchiveService.cs b/API/Interfaces/Services/IArchiveService.cs index ae9bddc98..f2567341a 100644 --- a/API/Interfaces/Services/IArchiveService.cs +++ b/API/Interfaces/Services/IArchiveService.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO.Compression; using System.Threading.Tasks; using API.Archive; +using API.Data.Metadata; namespace API.Interfaces.Services { @@ -12,7 +13,7 @@ namespace API.Interfaces.Services int GetNumberOfPagesFromArchive(string archivePath); string GetCoverImage(string archivePath, string fileName); bool IsValidArchive(string archivePath); - string GetSummaryInfo(string archivePath); + ComicInfo GetComicInfo(string archivePath); ArchiveLibrary CanOpen(string archivePath); bool ArchiveNeedsFlattening(ZipArchive archive); Task> CreateZipForDownload(IEnumerable files, string tempFolder); diff --git a/API/Interfaces/Services/IBookService.cs b/API/Interfaces/Services/IBookService.cs index cde2cad8e..e78669755 100644 --- a/API/Interfaces/Services/IBookService.cs +++ b/API/Interfaces/Services/IBookService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using API.Data.Metadata; using API.Parser; using VersOne.Epub; @@ -20,7 +21,7 @@ namespace API.Interfaces.Services /// Book Reference, needed for if you expect Import statements /// Task ScopeStyles(string stylesheetHtml, string apiBase, string filename, EpubBookRef book); - string GetSummaryInfo(string filePath); + ComicInfo GetComicInfo(string filePath); ParserInfo ParseInfo(string filePath); /// /// Extracts a PDF file's pages as images to an target directory diff --git a/API/Interfaces/Services/IScannerService.cs b/API/Interfaces/Services/IScannerService.cs index b67290bfc..bab0ca588 100644 --- a/API/Interfaces/Services/IScannerService.cs +++ b/API/Interfaces/Services/IScannerService.cs @@ -11,9 +11,8 @@ namespace API.Interfaces.Services /// cover images if forceUpdate is true. /// /// Library to scan against - /// Force overwriting for cover images - Task ScanLibrary(int libraryId, bool forceUpdate); + Task ScanLibrary(int libraryId); Task ScanLibraries(); - Task ScanSeries(int libraryId, int seriesId, bool forceUpdate, CancellationToken token); + Task ScanSeries(int libraryId, int seriesId, CancellationToken token); } } diff --git a/API/Interfaces/Services/ReaderService.cs b/API/Interfaces/Services/ReaderService.cs index eaa3b96d7..7eb2e1118 100644 --- a/API/Interfaces/Services/ReaderService.cs +++ b/API/Interfaces/Services/ReaderService.cs @@ -1,7 +1,6 @@  using System; using System.Collections.Generic; -using System.Data; using System.Linq; using System.Threading.Tasks; using API.Comparators; @@ -210,7 +209,7 @@ namespace API.Interfaces.Services /// -1 if nothing can be found public async Task GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) { - var volumes = (await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList(); var currentVolume = volumes.Single(v => v.Id == volumeId); var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); @@ -262,7 +261,7 @@ namespace API.Interfaces.Services /// -1 if nothing can be found public async Task GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId) { - var volumes = (await _unitOfWork.SeriesRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList(); + var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().ToList(); var currentVolume = volumes.Single(v => v.Id == volumeId); var currentChapter = currentVolume.Chapters.Single(c => c.Id == currentChapterId); diff --git a/API/Parser/Parser.cs b/API/Parser/Parser.cs index 0650faf4a..9bd2aab82 100644 --- a/API/Parser/Parser.cs +++ b/API/Parser/Parser.cs @@ -21,29 +21,28 @@ namespace API.Parser public const string SupportedExtensions = ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions; + private const RegexOptions MatchOptions = + RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.CultureInvariant; + public static readonly Regex FontSrcUrlRegex = new Regex(@"(src:url\(.{1})" + "([^\"']*)" + @"(.{1}\))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout); + MatchOptions, RegexTimeout); public static readonly Regex CssImportUrlRegex = new Regex("(@import\\s[\"|'])(?[\\w\\d/\\._-]+)([\"|'];?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout); + MatchOptions, RegexTimeout); private static readonly string XmlRegexExtensions = @"\.xml"; private static readonly Regex ImageRegex = new Regex(ImageFileExtensions, - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout); + MatchOptions, RegexTimeout); private static readonly Regex ArchiveFileRegex = new Regex(ArchiveFileExtensions, - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout); + MatchOptions, RegexTimeout); private static readonly Regex XmlRegex = new Regex(XmlRegexExtensions, - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout); + MatchOptions, RegexTimeout); private static readonly Regex BookFileRegex = new Regex(BookFileExtensions, - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout); + MatchOptions, RegexTimeout); private static readonly Regex CoverImageRegex = new Regex(@"(?.*)(\b|_)v(?\d+-?\d+)( |_)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // NEEDLESS_Vol.4_-Simeon_6_v2[SugoiSugoi].rar new Regex( @"(?.*)(\b|_)(?!\[)(vol\.?)(?\d+(-\d+)?)(?!\])", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Historys Strongest Disciple Kenichi_v11_c90-98.zip or Dance in the Vampire Bund v16-17 new Regex( @"(?.*)(\b|_)(?!\[)v(?\d+(-\d+)?)(?!\])", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), - // Kodomo no Jikan vol. 10 + 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+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"(?.*)(\b|_)(vol\.? ?)(?\d+(\.\d)?(-\d+)?(\.\d)?)", + MatchOptions, RegexTimeout), // Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( - @"(vol\.? ?)(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"(vol\.? ?)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), // Tonikaku Cawaii [Volume 11].cbz new Regex( - @"(volume )(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"(volume )(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), // Tower Of God S01 014 (CBT) (digital).cbz new Regex( @"(?.*)(\b|_|)(S(?\d+))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // vol_001-1.cbz for MangaPy default naming convention new Regex( - @"(vol_)(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"(vol_)(?\d+(\.\d)?)", + MatchOptions, RegexTimeout), }; private static readonly Regex[] MangaSeriesRegex = new[] @@ -95,167 +86,138 @@ namespace API.Parser // Grand Blue Dreaming - SP02 new Regex( @"(?.*)(\b|_|-|\s)(?:sp)\d", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // [SugoiSugoi]_NEEDLESS_Vol.2_-_Disk_The_Informant_5_[ENG].rar, Yuusha Ga Shinda! - Vol.tbd Chapter 27.001 V2 Infection ①.cbz new Regex( @"^(?.*)( |_)Vol\.?(\d+|tbd)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Mad Chimera World - Volume 005 - Chapter 026.cbz (couldn't figure out how to get Volume negative lookaround working on below regex), // The Duke of Death and His Black Maid - Vol. 04 Ch. 054.5 - V4 Omake new Regex( @"(?.+?)(\s|_|-)+(?:Vol(ume|\.)?(\s|_|-)+\d+)(\s|_|-)+(?:(Ch|Chapter|Ch)\.?)(\s|_|-)+(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, + MatchOptions, RegexTimeout), // Ichiban_Ushiro_no_Daimaou_v04_ch34_[VISCANS].zip, VanDread-v01-c01.zip new Regex( @"(?.*)(\b|_)v(?\d+-?\d*)(\s|_|-)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, + MatchOptions, RegexTimeout), // Gokukoku no Brynhildr - c001-008 (v01) [TrinityBAKumA], Black Bullet - v4 c17 [batoto] new Regex( @"(?.*)( - )(?:v|vo|c)\d", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Kedouin Makoto - Corpse Party Musume, Chapter 19 [Dametrans].zip new Regex( @"(?.*)(?:, Chapter )(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz new Regex( @"(?.*)(\s|_|-)(?!Vol)(\s|_|-)(?:Chapter)(\s|_|-)(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + 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+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // [xPearse] Kyochuu Rettou Volume 1 [English] [Manga] [Volume Scans] new Regex( @"(?.*) (\b|_|-)(vol)(ume)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, + MatchOptions, RegexTimeout), //Knights of Sidonia c000 (S2 LE BD Omake - BLAME!) [Habanero Scans] new Regex( @"(?.*)(\bc\d+\b)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), //Tonikaku Cawaii [Volume 11], Darling in the FranXX - Volume 01.cbz new Regex( @"(?.*)(?: _|-|\[|\()\s?vol(ume)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Momo The Blood Taker - Chapter 027 Violent Emotion.cbz, Grand Blue Dreaming - SP02 Extra (2019) (Digital) (danke-Empire).cbz new Regex( @"^(?(?!Vol).+?)(?:(ch(apter|\.)(\b|_|-|\s))|sp)\d", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Historys Strongest Disciple Kenichi_v11_c90-98.zip, Killing Bites Vol. 0001 Ch. 0001 - Galactica Scanlations (gb) new Regex( @"(?.*) (\b|_|-)(v|ch\.?|c)\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), //Ichinensei_ni_Nacchattara_v01_ch01_[Taruby]_v1.1.zip must be before [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip // due to duplicate version identifiers in file. new Regex( @"(?.*)(v|s)\d+(-\d+)?(_|\s)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), //[Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip new Regex( @"(?.*)(v|s)\d+(-\d+)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz new Regex( @"(?.*) (?\d+) (?:\(\d{4}\)) ", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Goblin Slayer - Brand New Day 006.5 (2019) (Digital) (danke-Empire) new Regex( @"(?.*) (?\d+(?:.\d+|-\d+)?) \(\d{4}\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Noblesse - Episode 429 (74 Pages).7z new Regex( @"(?.*)(\s|_)(?:Episode|Ep\.?)(\s|_)(?\d+(?:.\d+|-\d+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Akame ga KILL! ZERO (2016-2019) (Digital) (LuCaZ) new Regex( @"(?.*)\(\d", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Tonikaku Kawaii (Ch 59-67) (Ongoing) new Regex( @"(?.*)(\s|_)\((c\s|ch\s|chapter\s)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Black Bullet (This is very loose, keep towards bottom) new Regex( @"(?.*)(_)(v|vo|c|volume)( |_)\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar new Regex( @"(?.*)( |_)(vol\d+)?( |_)(?:Chp\.? ?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Mahoutsukai to Deshi no Futekisetsu na Kankei Chp. 1 new Regex( @"(?.*)( |_)(?:Chp.? ?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Corpse Party -The Anthology- Sachikos game of love Hysteric Birthday 2U Chapter 01 new Regex( @"^(?!Vol)(?.*)( |_)Chapter( |_)(\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Fullmetal Alchemist chapters 101-108.cbz new Regex( @"^(?!vol)(?.*)( |_)(chapters( |_)?)\d+-?\d*", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Umineko no Naku Koro ni - Episode 1 - Legend of the Golden Witch #1 new Regex( @"^(?!Vol\.?)(?.*)( |_|-)(?.*)ch\d+-?\d?", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Magi - Ch.252-005.cbz new Regex( @"(?.*)( ?- ?)Ch\.\d+-?\d*", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // [BAA]_Darker_than_Black_Omake-1.zip new Regex( @"^(?!Vol)(?.*)(-)\d+-?\d*", // This catches a lot of stuff ^(?!Vol)(?.*)( |_)(\d+) - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Kodoja #001 (March 2016) new Regex( @"(?.*)(\s|_|-)#", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Baketeriya ch01-05.zip, Akiiro Bousou Biyori - 01.jpg, Beelzebub_172_RHS.zip, Cynthia the Mission 29.rar, A Compendium of Ghosts - 031 - The Third Story_ Part 12 (Digital) (Cobalt001) new Regex( @"^(?!Vol\.?)(?.+?)( |_|-)(?.*)( |_|-)(ch?)\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), }; private static readonly Regex[] ComicSeriesRegex = new[] @@ -263,110 +225,79 @@ namespace API.Parser // Invincible Vol 01 Family matters (2005) (Digital) new Regex( @"(?.*)(\b|_)(vol\.?)( |_)(?\d+(-\d+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // Batman Beyond 2.0 001 (2013) + new Regex( + @"^(?.+?\S\.\d) (?\d+)", + MatchOptions, RegexTimeout), // 04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS) new Regex( - @"^(?\d+) (- |_)?(?.*(\d{4})?)( |_)(\(|\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"^(?\d+)\s(-\s|_)(?.*(\d{4})?)( |_)(\(|\d+)", + MatchOptions, RegexTimeout), // 01 Spider-Man & Wolverine 01.cbr new Regex( - @"^(?\d+) (?:- )?(?.*) (\d+)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"^(?\d+)\s(?:-\s)(?.*) (\d+)?", + MatchOptions, RegexTimeout), // Batman & Wildcat (1 of 3) new Regex( @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( @"^(?.*)(?: |_)v\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Amazing Man Comics chapter 25 new Regex( @"^(?.*)(?: |_)c(hapter) \d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Amazing Man Comics issue #25 new Regex( @"^(?.*)(?: |_)i(ssue) #\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Batman Wayne Family Adventures - Ep. 001 - Moving In new Regex( @"^(?.+?)(\s|_|-)?(?:Ep\.?)(\s|_|-)+\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), - // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + MatchOptions, RegexTimeout), + // Batgirl Vol.2000 #57 (December, 2004) new Regex( - @"^(?.+?)(?: \d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"^(?.+?)Vol\.?\s?#?(?:\d+)", + MatchOptions, RegexTimeout), // Batman & Robin the Teen Wonder #0 new Regex( @"^(?.*)(?: |_)#\d+", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) + new Regex( + @"^(?.+?)(?: \d+)", + MatchOptions, RegexTimeout), // Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005) new Regex( @"^(?.*)(?: |_)(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // The First Asterix Frieze (WebP by Doc MaKS) new Regex( @"^(?.*)(?: |_)(?!\(\d{4}|\d{4}-\d{2}\))\(", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) + new Regex( + @"^(?.+?)-(chapter-)?(?\d+)", + MatchOptions, RegexTimeout), // MUST BE LAST: Batman & Daredevil - King of New York new Regex( @"^(?.*)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), }; private static readonly Regex[] ComicVolumeRegex = new[] { - // // 04 - Asterix the Gladiator (1964) (Digital-Empire) (WebP by Doc MaKS) - // new Regex( - // @"^(?\d+) (- |_)?(?.*(\d{4})?)( |_)(\(|\d+)", - // RegexOptions.IgnoreCase | RegexOptions.Compiled, - // RegexTimeout), - // // 01 Spider-Man & Wolverine 01.cbr - // new Regex( - // @"^(?\d+) (?:- )?(?.*) (\d+)?", - // RegexOptions.IgnoreCase | RegexOptions.Compiled, - // RegexTimeout), - // // Batman & Wildcat (1 of 3) - // new Regex( - // @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", - // RegexOptions.IgnoreCase | RegexOptions.Compiled, - // RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( @"^(?.*)(?: |_)v(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), - // Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005) - // BUG: Negative lookbehind has to be fixed width - // NOTE: The case this is built for does not make much sense. - // new Regex( - // @"^(?.+?)(?\d+)", - // RegexOptions.IgnoreCase | RegexOptions.Compiled, - // RegexTimeout), - - // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) - // new Regex( - // @"^(?.+?)(?\d+))", - // RegexOptions.IgnoreCase | RegexOptions.Compiled, - // RegexTimeout), - // // Batman & Robin the Teen Wonder #0 - // new Regex( - // @"^(?.*)(?: |_)#(?\d+)", - // RegexOptions.IgnoreCase | RegexOptions.Compiled, - // RegexTimeout), + MatchOptions, RegexTimeout), + // Batgirl Vol.2000 #57 (December, 2004) + new Regex( + @"^(?.+?)(?:\s|_)vol\.?\s?(?\d+)", + MatchOptions, RegexTimeout), }; private static readonly Regex[] ComicChapterRegex = new[] @@ -374,59 +305,68 @@ namespace API.Parser // Batman & Wildcat (1 of 3) new Regex( @"(?.*(\d{4})?)( |_)(?:\((?\d+) of \d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Batman Beyond 04 (of 6) (1999) new Regex( @"(?.+?)(?\d+)(\s|_|-)?\(of", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // Batman Beyond 2.0 001 (2013) + new Regex( + @"^(?.+?\S\.\d) (?\d+)", + MatchOptions, RegexTimeout), // Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( @"^(?.+?)(?: |_)v(?\d+)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // Batman & Robin the Teen Wonder #0 + new Regex( + @"^(?.+?)(?:\s|_)#(?\d+)", + MatchOptions, RegexTimeout), // Invincible 070.5 - Invincible Returns 1 (2010) (digital) (Minutemen-InnerDemons).cbr new Regex( @"^(?.+?)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)(c? ?)-", - RegexOptions.IgnoreCase | RegexOptions.Compiled, + MatchOptions, RegexTimeout), + // Batgirl Vol.2000 #57 (December, 2004) + new Regex( + @"^(?.+?)(?:vol\.?\d+)\s#(?\d+)", + MatchOptions, RegexTimeout), // Batman & Catwoman - Trail of the Gun 01, Batman & Grendel (1996) 01 - Devil's Bones, Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus) new Regex( @"^(?.+?)(?: (?\d+))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), - // Batman & Robin the Teen Wonder #0 - new Regex( - @"^(?.+?)(?:\s|_)#(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // Saga 001 (2012) (Digital) (Empire-Zone) new Regex( @"(?.+?)(?: |_)(c? ?)(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)\s\(\d{4}", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Amazing Man Comics chapter 25 new Regex( @"^(?!Vol)(?.+?)( |_)c(hapter)( |_)(?\d*)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Amazing Man Comics issue #25 new Regex( @"^(?!Vol)(?.+?)( |_)i(ssue)( |_) #(?\d*)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + // spawn-123, spawn-chapter-123 (from https://github.com/Girbons/comics-downloader) + new Regex( + @"^(?.+?)-(chapter-)?(?\d+)", + MatchOptions, RegexTimeout), + // Cyberpunk 2077 - Your Voice 01 + // new Regex( + // @"^(?.+?\s?-\s?(?:.+?))(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)$", + // MatchOptions, + // RegexTimeout), }; private static readonly Regex[] ReleaseGroupRegex = new[] { // [TrinityBAKumA Finella&anon], [BAA]_, [SlowManga&OverloadScans], [batoto] new Regex(@"(?:\[(?(?!\s).+?(?(?!\s).+?(?(\d+(\.\d)?)-?(\d+(\.\d)?)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // [Suihei Kiki]_Kasumi_Otoko_no_Ko_[Taruby]_v1.1.zip new Regex( @"v\d+\.(?\d+(?:.\d+|-\d+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Umineko no Naku Koro ni - Episode 3 - Banquet of the Golden Witch #02.cbz (Rare case, if causes issue remove) new Regex( @"^(?.*)(?: |_)#(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Green Worldz - Chapter 027, Kimi no Koto ga Daidaidaidaidaisuki na 100-nin no Kanojo Chapter 11-10 new Regex( @"^(?!Vol)(?.*)\s?(?\d+(?:\.?[\d-]+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Hinowa ga CRUSH! 018 (2019) (Digital) (LuCaZ).cbz, Hinowa ga CRUSH! 018.5 (2019) (Digital) (LuCaZ).cbz new Regex( - @"^(?!Vol)(?.+?)\s(?\d+(?:.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"^(?!Vol)(?.+?)(?\d+(?:.\d+|-\d+)?)(?:\s\(\d{4}\))?(\b|_|-)", + MatchOptions, RegexTimeout), // Tower Of God S01 014 (CBT) (digital).cbz new Regex( @"(?.*)\sS(?\d+)\s(?\d+(?:.\d+|-\d+)?)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Beelzebub_01_[Noodles].zip, Beelzebub_153b_RHS.zip new Regex( - @"^((?!v|vo|vol|Volume).)*(\s|_)(?\.?\d+(?:.\d+|-\d+)?)(?b)?(\s|_|\[|\()", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + @"^((?!v|vo|vol|Volume).)*(\s|_)(?\.?\d+(?:.\d+|-\d+)?)(?b)?(\s|_|\[|\()", + MatchOptions, RegexTimeout), // Yumekui-Merry_DKThias_Chapter21.zip new Regex( @"Chapter(?\d+(-\d+)?)", //(?:.\d+|-\d+)? - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // [Hidoi]_Amaenaideyo_MS_vol01_chp02.rar new Regex( @"(?.*)(\s|_)(vol\d+)?(\s|_)Chp\.? ?(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Vol 1 Chapter 2 new Regex( @"(?((vol|volume|v))?(\s|_)?\.?\d+)(\s|_)(Chp|Chapter)\.?(\s|_)?(?\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), }; private static readonly Regex[] MangaEditionRegex = { // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz new Regex( @"(?({|\(|\[).* Edition(}|\)|\]))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Tenjo Tenge {Full Contact Edition} v01 (2011) (Digital) (ASTC).cbz new Regex( @"(\b|_)(?Omnibus(( |_)?Edition)?)(\b|_)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // To Love Ru v01 Uncensored (Ch.001-007) new Regex( @"(\b|_)(?Uncensored)(\b|_)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // AKIRA - c003 (v01) [Full Color] [Darkhorse].cbz new Regex( @"(\b|_)(?Full(?: |_)Color)(\b|_)?", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), }; private static readonly Regex[] CleanupRegex = @@ -511,18 +437,15 @@ namespace API.Parser // (), {}, [] new Regex( @"(?(\{\}|\[\]|\(\)))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // (Complete) new Regex( @"(?(\{Complete\}|\[Complete\]|\(Complete\)))", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), // Anything in parenthesis new Regex( @"\(.*\)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), }; private static readonly Regex[] MangaSpecialRegex = @@ -530,15 +453,21 @@ namespace API.Parser // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. new Regex( @"(?Specials?|OneShot|One\-Shot|Omake|Extra( Chapter)?|Art Collection|Side( |_)Stories|Bonus)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout), + MatchOptions, RegexTimeout), + }; + + private static readonly Regex[] ComicSpecialRegex = + { + // All Keywords, does not account for checking if contains volume/chapter identification. Parser.Parse() will handle. + new Regex( + @"(?Specials?|OneShot|One\-Shot|Extra( Chapter)?|Book \d.+?|Compendium \d.+?|Omnibus \d.+?|[_\s\-]TPB[_\s\-]|FCBD \d.+?|Absolute \d.+?|Preview \d.+?|Art Collection|Side( |_)Stories|Bonus)", + MatchOptions, RegexTimeout), }; // If SP\d+ is in the filename, we force treat it as a special regardless if volume or chapter might have been found. private static readonly Regex SpecialMarkerRegex = new Regex( @"(?SP\d+)", - RegexOptions.IgnoreCase | RegexOptions.Compiled, - RegexTimeout + MatchOptions, RegexTimeout ); @@ -552,7 +481,7 @@ namespace API.Parser /// or null if Series was empty public static ParserInfo Parse(string filePath, string rootPath, LibraryType type = LibraryType.Manga) { - var fileName = Path.GetFileName(filePath); + var fileName = Path.GetFileNameWithoutExtension(filePath); ParserInfo ret; if (IsEpub(filePath)) @@ -562,7 +491,7 @@ namespace API.Parser Chapters = ParseChapter(fileName) ?? ParseComicChapter(fileName), Series = ParseSeries(fileName) ?? ParseComicSeries(fileName), Volumes = ParseVolume(fileName) ?? ParseComicVolume(fileName), - Filename = fileName, + Filename = Path.GetFileName(filePath), Format = ParseFormat(filePath), FullFilePath = filePath }; @@ -574,14 +503,14 @@ namespace API.Parser Chapters = type == LibraryType.Manga ? ParseChapter(fileName) : ParseComicChapter(fileName), Series = type == LibraryType.Manga ? ParseSeries(fileName) : ParseComicSeries(fileName), Volumes = type == LibraryType.Manga ? ParseVolume(fileName) : ParseComicVolume(fileName), - Filename = fileName, + Filename = Path.GetFileName(filePath), Format = ParseFormat(filePath), Title = Path.GetFileNameWithoutExtension(fileName), FullFilePath = filePath }; } - if (IsImage(filePath) && IsCoverImage(fileName)) return null; + if (IsImage(filePath) && IsCoverImage(filePath)) return null; if (IsImage(filePath)) { @@ -600,7 +529,7 @@ namespace API.Parser var edition = ParseEdition(fileName); if (!string.IsNullOrEmpty(edition)) { - ret.Series = CleanTitle(ret.Series.Replace(edition, "")); + ret.Series = CleanTitle(ret.Series.Replace(edition, ""), type is LibraryType.Comic); ret.Edition = edition; } @@ -625,11 +554,11 @@ namespace API.Parser if (string.IsNullOrEmpty(ret.Series)) { - ret.Series = CleanTitle(fileName); + ret.Series = CleanTitle(fileName, type is LibraryType.Comic); } // Pdfs may have .pdf in the series name, remove that - if (IsPdf(fileName) && ret.Series.ToLower().EndsWith(".pdf")) + if (IsPdf(filePath) && ret.Series.ToLower().EndsWith(".pdf")) { ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length); } @@ -673,7 +602,7 @@ namespace API.Parser if ((string.IsNullOrEmpty(series) && i == fallbackFolders.Count - 1)) { - ret.Series = CleanTitle(folder); + ret.Series = CleanTitle(folder, type is LibraryType.Comic); break; } @@ -750,6 +679,23 @@ namespace API.Parser return string.Empty; } + public static string ParseComicSpecial(string filePath) + { + foreach (var regex in ComicSpecialRegex) + { + var matches = regex.Matches(filePath); + foreach (Match match in matches) + { + if (match.Groups["Special"].Success && match.Groups["Special"].Value != string.Empty) + { + return match.Groups["Special"].Value; + } + } + } + + return string.Empty; + } + public static string ParseSeries(string filename) { foreach (var regex in MangaSeriesRegex) @@ -775,7 +721,7 @@ namespace API.Parser { if (match.Groups["Series"].Success && match.Groups["Series"].Value != string.Empty) { - return CleanTitle(match.Groups["Series"].Value); + return CleanTitle(match.Groups["Series"].Value, true); } } } @@ -793,12 +739,8 @@ namespace API.Parser if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue; var value = match.Groups["Volume"].Value; - if (!value.Contains("-")) return RemoveLeadingZeroes(match.Groups["Volume"].Value); - var tokens = value.Split("-"); - var from = RemoveLeadingZeroes(tokens[0]); - var to = RemoveLeadingZeroes(tokens[1]); - return $"{@from}-{to}"; - + var hasPart = match.Groups["Part"].Success; + return FormatValue(value, hasPart); } } @@ -815,18 +757,32 @@ namespace API.Parser if (!match.Groups["Volume"].Success || match.Groups["Volume"] == Match.Empty) continue; var value = match.Groups["Volume"].Value; - if (!value.Contains("-")) return RemoveLeadingZeroes(match.Groups["Volume"].Value); - var tokens = value.Split("-"); - var from = RemoveLeadingZeroes(tokens[0]); - var to = RemoveLeadingZeroes(tokens[1]); - return $"{@from}-{to}"; - + var hasPart = match.Groups["Part"].Success; + return FormatValue(value, hasPart); } } return DefaultVolume; } + private static string FormatValue(string value, bool hasPart) + { + if (!value.Contains("-")) + { + return RemoveLeadingZeroes(hasPart ? AddChapterPart(value) : value); + } + + var tokens = value.Split("-"); + var from = RemoveLeadingZeroes(tokens[0]); + if (tokens.Length == 2) + { + var to = RemoveLeadingZeroes(hasPart ? AddChapterPart(tokens[1]) : tokens[1]); + return $"{@from}-{to}"; + } + + return @from; + } + public static string ParseChapter(string filename) { foreach (var regex in MangaChapterRegex) @@ -837,24 +793,9 @@ namespace API.Parser if (!match.Groups["Chapter"].Success || match.Groups["Chapter"] == Match.Empty) continue; var value = match.Groups["Chapter"].Value; - var hasChapterPart = match.Groups["ChapterPart"].Success; - - if (!value.Contains("-")) - { - return RemoveLeadingZeroes(hasChapterPart ? AddChapterPart(value) : value); - } - - var tokens = value.Split("-"); - var from = RemoveLeadingZeroes(tokens[0]); - if (tokens.Length == 2) - { - var to = RemoveLeadingZeroes(hasChapterPart ? AddChapterPart(tokens[1]) : tokens[1]); - return $"{@from}-{to}"; - } - - return from; - + var hasPart = match.Groups["Part"].Success; + return FormatValue(value, hasPart); } } @@ -881,16 +822,8 @@ namespace API.Parser if (match.Groups["Chapter"].Success && match.Groups["Chapter"] != Match.Empty) { var value = match.Groups["Chapter"].Value; - - if (value.Contains("-")) - { - var tokens = value.Split("-"); - var from = RemoveLeadingZeroes(tokens[0]); - var to = RemoveLeadingZeroes(tokens[1]); - return $"{from}-{to}"; - } - - return RemoveLeadingZeroes(match.Groups["Chapter"].Value); + var hasPart = match.Groups["Part"].Success; + return FormatValue(value, hasPart); } } @@ -908,12 +841,30 @@ namespace API.Parser { if (match.Success) { - title = title.Replace(match.Value, "").Trim(); + title = title.Replace(match.Value, string.Empty).Trim(); } } } + // TODO: Since we have loops like this, think about using a method foreach (var regex in MangaEditionRegex) + { + var matches = regex.Matches(title); + foreach (Match match in matches) + { + if (match.Success) + { + title = title.Replace(match.Value, string.Empty).Trim(); + } + } + } + + return title; + } + + private static string RemoveMangaSpecialTags(string title) + { + foreach (var regex in MangaSpecialRegex) { var matches = regex.Matches(title); foreach (Match match in matches) @@ -928,9 +879,9 @@ namespace API.Parser return title; } - private static string RemoveSpecialTags(string title) + private static string RemoveComicSpecialTags(string title) { - foreach (var regex in MangaSpecialRegex) + foreach (var regex in ComicSpecialRegex) { var matches = regex.Matches(title); foreach (Match match in matches) @@ -954,14 +905,16 @@ namespace API.Parser /// /// /// + /// /// - public static string CleanTitle(string title) + public static string CleanTitle(string title, bool isComic = false) { title = RemoveReleaseGroup(title); title = RemoveEditionTagHolders(title); - title = RemoveSpecialTags(title); + title = isComic ? RemoveComicSpecialTags(title) : RemoveMangaSpecialTags(title); + title = title.Replace("_", " ").Trim(); if (title.EndsWith("-") || title.EndsWith(",")) @@ -1009,7 +962,7 @@ namespace API.Parser private static string PerformPadding(string number) { - var num = Int32.Parse(number); + var num = int.Parse(number); return num switch { < 10 => "00" + num, @@ -1064,7 +1017,7 @@ namespace API.Parser public static string Normalize(string name) { - return Regex.Replace(name.ToLower(), "[^a-zA-Z0-9]", string.Empty); + return NormalizeRegex.Replace(name, string.Empty).ToLower(); } diff --git a/API/Program.cs b/API/Program.cs index eddc4cb4e..06c860366 100644 --- a/API/Program.cs +++ b/API/Program.cs @@ -1,19 +1,11 @@ using System; -using System.Collections.Generic; -using System.Data; using System.IO; -using System.Linq; using System.Security.Cryptography; -using System.Threading; -using System.Threading.Channels; using System.Threading.Tasks; using API.Data; using API.Entities; -using API.Helpers; -using API.Interfaces; using API.Services; using Kavita.Common; -using Kavita.Common.EnvironmentInfo; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Server.Kestrel.Core; @@ -21,9 +13,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; -using Microsoft.IO; -using NetVips; -using Sentry; namespace API { @@ -103,62 +92,6 @@ namespace API opts.ListenAnyIP(HttpPort, options => { options.Protocols = HttpProtocols.Http1AndHttp2; }); }); - var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); - if (environment != Environments.Development) - { - webBuilder.UseSentry(options => - { - options.Dsn = "https://40f4e7b49c094172a6f99d61efb2740f@o641015.ingest.sentry.io/5757423"; - options.MaxBreadcrumbs = 200; - options.AttachStacktrace = true; - options.Debug = false; - options.SendDefaultPii = false; - options.DiagnosticLevel = SentryLevel.Debug; - options.ShutdownTimeout = TimeSpan.FromSeconds(5); - options.Release = BuildInfo.Version.ToString(); - options.AddExceptionFilterForType(); - options.AddExceptionFilterForType(); - options.AddExceptionFilterForType(); - options.AddExceptionFilterForType(); - - options.BeforeSend = sentryEvent => - { - if (sentryEvent.Exception != null - && sentryEvent.Exception.Message.StartsWith("[GetCoverImage]") - && sentryEvent.Exception.Message.StartsWith("[BookService]") - && sentryEvent.Exception.Message.StartsWith("[ExtractArchive]") - && sentryEvent.Exception.Message.StartsWith("[GetSummaryInfo]") - && sentryEvent.Exception.Message.StartsWith("[GetSummaryInfo]") - && sentryEvent.Exception.Message.StartsWith("[GetNumberOfPagesFromArchive]") - && sentryEvent.Exception.Message.Contains("EPUB parsing error") - && sentryEvent.Exception.Message.Contains("Unsupported EPUB version") - && sentryEvent.Exception.Message.Contains("Incorrect EPUB") - && sentryEvent.Exception.Message.Contains("Access is Denied")) - { - return null; // Don't send this event to Sentry - } - - sentryEvent.ServerName = null; // Never send Server Name to Sentry - return sentryEvent; - }; - - options.ConfigureScope(scope => - { - scope.User = new User() - { - Id = HashUtil.AnonymousToken() - }; - scope.Contexts.App.Name = BuildInfo.AppName; - scope.Contexts.App.Version = BuildInfo.Version.ToString(); - scope.Contexts.App.StartTime = DateTime.UtcNow; - scope.Contexts.App.Hash = HashUtil.AnonymousToken(); - scope.Contexts.App.Build = BuildInfo.Release; - scope.SetTag("culture", Thread.CurrentThread.CurrentCulture.Name); - scope.SetTag("branch", BuildInfo.Branch); - }); - }); - } - webBuilder.UseStartup(); }); } diff --git a/API/Services/AccountService.cs b/API/Services/AccountService.cs new file mode 100644 index 000000000..0cc720bb6 --- /dev/null +++ b/API/Services/AccountService.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using API.Entities; +using API.Errors; +using API.Interfaces.Services; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.Logging; + +namespace API.Services +{ + public class AccountService : IAccountService + { + private readonly UserManager _userManager; + private readonly ILogger _logger; + public const string DefaultPassword = "[k.2@RZ!mxCQkJzE"; + + public AccountService(UserManager userManager, ILogger logger) + { + _userManager = userManager; + _logger = logger; + } + + public async Task> ChangeUserPassword(AppUser user, string newPassword) + { + foreach (var validator in _userManager.PasswordValidators) + { + var validationResult = await validator.ValidateAsync(_userManager, user, newPassword); + if (!validationResult.Succeeded) + { + return validationResult.Errors.Select(e => new ApiException(400, e.Code, e.Description)); + } + } + + var result = await _userManager.RemovePasswordAsync(user); + if (!result.Succeeded) + { + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); + } + + + result = await _userManager.AddPasswordAsync(user, newPassword); + if (!result.Succeeded) + { + _logger.LogError("Could not update password"); + return result.Errors.Select(e => new ApiException(400, e.Code, e.Description)); + } + + return new List(); + } + } +} diff --git a/API/Services/ArchiveService.cs b/API/Services/ArchiveService.cs index bfa36595c..621d42322 100644 --- a/API/Services/ArchiveService.cs +++ b/API/Services/ArchiveService.cs @@ -8,15 +8,14 @@ using System.Threading.Tasks; using System.Xml.Serialization; using API.Archive; using API.Comparators; +using API.Data.Metadata; using API.Extensions; using API.Interfaces.Services; using API.Services.Tasks; using Kavita.Common; using Microsoft.Extensions.Logging; -using Microsoft.IO; using SharpCompress.Archives; using SharpCompress.Common; -using Image = NetVips.Image; namespace API.Services { @@ -28,14 +27,12 @@ namespace API.Services { private readonly ILogger _logger; private readonly IDirectoryService _directoryService; - private readonly NaturalSortComparer _comparer; private const string ComicInfoFilename = "comicinfo"; public ArchiveService(ILogger logger, IDirectoryService directoryService) { _logger = logger; _directoryService = directoryService; - _comparer = new NaturalSortComparer(); } /// @@ -81,13 +78,11 @@ namespace API.Services { case ArchiveLibrary.Default: { - _logger.LogDebug("Using default compression handling"); - using ZipArchive archive = ZipFile.OpenRead(archivePath); + using var archive = ZipFile.OpenRead(archivePath); return archive.Entries.Count(e => !Parser.Parser.HasBlacklistedFolderInPath(e.FullName) && Parser.Parser.IsImage(e.FullName)); } case ArchiveLibrary.SharpCompress: { - _logger.LogDebug("Using SharpCompress compression handling"); using var archive = ArchiveFactory.Open(archivePath); return archive.Entries.Count(entry => !entry.IsDirectory && !Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) @@ -130,7 +125,7 @@ namespace API.Services /// Entry name of match, null if no match public string FirstFileEntry(IEnumerable entryFullNames) { - var result = entryFullNames.OrderBy(Path.GetFileName, _comparer) + var result = entryFullNames.OrderBy(Path.GetFileName, new NaturalSortComparer()) .FirstOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x) && Parser.Parser.IsImage(x) && !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)); @@ -160,7 +155,6 @@ namespace API.Services { case ArchiveLibrary.Default: { - _logger.LogDebug("Using default compression handling"); using var archive = ZipFile.OpenRead(archivePath); var entryNames = archive.Entries.Select(e => e.FullName).ToArray(); @@ -172,7 +166,6 @@ namespace API.Services } case ArchiveLibrary.SharpCompress: { - _logger.LogDebug("Using SharpCompress compression handling"); using var archive = ArchiveFactory.Open(archivePath); var entryNames = archive.Entries.Where(archiveEntry => !archiveEntry.IsDirectory).Select(e => e.Key).ToList(); @@ -301,66 +294,69 @@ namespace API.Services return null; } - public string GetSummaryInfo(string archivePath) + public ComicInfo GetComicInfo(string archivePath) { - var summary = string.Empty; - if (!IsValidArchive(archivePath)) return summary; + if (!IsValidArchive(archivePath)) return null; - ComicInfo info = null; try { - if (!File.Exists(archivePath)) return summary; + if (!File.Exists(archivePath)) return null; var libraryHandler = CanOpen(archivePath); switch (libraryHandler) { case ArchiveLibrary.Default: { - _logger.LogTrace("Using default compression handling"); using var archive = ZipFile.OpenRead(archivePath); - var entry = archive.Entries.SingleOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName) - && Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename - && !Path.GetFileNameWithoutExtension(x.Name).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith) - && Parser.Parser.IsXml(x.FullName)); + var entry = archive.Entries.SingleOrDefault(x => + !Parser.Parser.HasBlacklistedFolderInPath(x.FullName) + && Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename + && !Path.GetFileNameWithoutExtension(x.Name) + .StartsWith(Parser.Parser.MacOsMetadataFileStartsWith) + && Parser.Parser.IsXml(x.FullName)); if (entry != null) { using var stream = entry.Open(); var serializer = new XmlSerializer(typeof(ComicInfo)); - info = (ComicInfo) serializer.Deserialize(stream); + return (ComicInfo) serializer.Deserialize(stream); } + break; } case ArchiveLibrary.SharpCompress: { - _logger.LogTrace("Using SharpCompress compression handling"); using var archive = ArchiveFactory.Open(archivePath); - info = FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory - && !Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) - && !Path.GetFileNameWithoutExtension(entry.Key).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith) + return FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory + && !Parser.Parser + .HasBlacklistedFolderInPath( + Path.GetDirectoryName( + entry.Key) ?? string.Empty) + && !Path + .GetFileNameWithoutExtension( + entry.Key).StartsWith(Parser + .Parser + .MacOsMetadataFileStartsWith) && Parser.Parser.IsXml(entry.Key))); - break; } case ArchiveLibrary.NotSupported: - _logger.LogWarning("[GetSummaryInfo] This archive cannot be read: {ArchivePath}", archivePath); - return summary; + _logger.LogWarning("[GetComicInfo] This archive cannot be read: {ArchivePath}", archivePath); + return null; default: - _logger.LogWarning("[GetSummaryInfo] There was an exception when reading archive stream: {ArchivePath}", archivePath); - return summary; - } - - if (info != null) - { - return info.Summary; + _logger.LogWarning( + "[GetComicInfo] There was an exception when reading archive stream: {ArchivePath}", + archivePath); + return null; } } catch (Exception ex) { - _logger.LogWarning(ex, "[GetSummaryInfo] There was an exception when reading archive stream: {Filepath}", archivePath); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception when reading archive stream: {Filepath}", archivePath); } - return summary; + return null; } + private static void ExtractArchiveEntities(IEnumerable entries, string extractPath) { DirectoryService.ExistOrCreate(extractPath); @@ -410,14 +406,12 @@ namespace API.Services { case ArchiveLibrary.Default: { - _logger.LogDebug("Using default compression handling"); using var archive = ZipFile.OpenRead(archivePath); ExtractArchiveEntries(archive, extractPath); break; } case ArchiveLibrary.SharpCompress: { - _logger.LogDebug("Using SharpCompress compression handling"); using var archive = ArchiveFactory.Open(archivePath); ExtractArchiveEntities(archive.Entries.Where(entry => !entry.IsDirectory && !Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty) diff --git a/API/Services/BookService.cs b/API/Services/BookService.cs index 6231de20a..a114b3bd6 100644 --- a/API/Services/BookService.cs +++ b/API/Services/BookService.cs @@ -4,12 +4,12 @@ using System.Drawing; using System.Drawing.Imaging; using System.IO; using System.Linq; -using System.Net; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Web; +using API.Data.Metadata; using API.Entities.Enums; using API.Interfaces.Services; using API.Parser; @@ -165,22 +165,43 @@ namespace API.Services return RemoveWhiteSpaceFromStylesheets(stylesheet.ToCss()); } - public string GetSummaryInfo(string filePath) + public ComicInfo GetComicInfo(string filePath) { - if (!IsValidFile(filePath) || Parser.Parser.IsPdf(filePath)) return string.Empty; - + if (!IsValidFile(filePath) || Parser.Parser.IsPdf(filePath)) return null; try { using var epubBook = EpubReader.OpenBook(filePath); - return epubBook.Schema.Package.Metadata.Description; + var publicationDate = + epubBook.Schema.Package.Metadata.Dates.FirstOrDefault(date => date.Event == "publication")?.Date; + + var info = new ComicInfo() + { + Summary = epubBook.Schema.Package.Metadata.Description, + Writer = string.Join(",", epubBook.Schema.Package.Metadata.Creators), + Publisher = string.Join(",", epubBook.Schema.Package.Metadata.Publishers), + Month = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Month : 0, + Year = !string.IsNullOrEmpty(publicationDate) ? DateTime.Parse(publicationDate).Year : 0, + }; + // Parse tags not exposed via Library + foreach (var metadataItem in epubBook.Schema.Package.Metadata.MetaItems) + { + switch (metadataItem.Name) + { + case "calibre:rating": + info.UserRating = float.Parse(metadataItem.Content); + break; + } + } + + return info; } catch (Exception ex) { - _logger.LogWarning(ex, "[BookService] There was an exception getting summary, defaulting to empty string"); + _logger.LogWarning(ex, "[GetComicInfo] There was an exception getting metadata"); } - return string.Empty; + return null; } private bool IsValidFile(string filePath) @@ -393,7 +414,7 @@ namespace API.Services /// public string GetCoverImage(string fileFilePath, string fileName) { - if (!IsValidFile(fileFilePath)) return String.Empty; + if (!IsValidFile(fileFilePath)) return string.Empty; if (Parser.Parser.IsPdf(fileFilePath)) { @@ -411,8 +432,8 @@ namespace API.Services ?? epubBook.Content.Images.Values.FirstOrDefault(); if (coverImageContent == null) return string.Empty; + using var stream = coverImageContent.GetContentStream(); - using var stream = StreamManager.GetStream("BookService.GetCoverImage", coverImageContent.ReadContent()); return ImageService.WriteCoverThumbnail(stream, fileName); } catch (Exception ex) diff --git a/API/Services/ComicInfo.cs b/API/Services/ComicInfo.cs deleted file mode 100644 index 55e823ee4..000000000 --- a/API/Services/ComicInfo.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace API.Services -{ - public class ComicInfo - { - public string Summary { get; set; } - public string Title { get; set; } - public string Series { get; set; } - public string Notes { get; set; } - public string Publisher { get; set; } - public string Genre { get; set; } - public int PageCount { get; set; } - // ReSharper disable once InconsistentNaming - public string LanguageISO { get; set; } - public string Web { get; set; } - } -} \ No newline at end of file diff --git a/API/Services/ImageService.cs b/API/Services/ImageService.cs index 0f0f3aa16..c2b3d4126 100644 --- a/API/Services/ImageService.cs +++ b/API/Services/ImageService.cs @@ -46,7 +46,7 @@ namespace API.Services var firstImage = _directoryService.GetFilesWithExtension(directory, Parser.Parser.ImageFileExtensions) .OrderBy(f => f, new NaturalSortComparer()).FirstOrDefault(); - + return firstImage; } @@ -73,7 +73,7 @@ namespace API.Services { using var thumbnail = Image.Thumbnail(path, ThumbnailWidth); var filename = fileName + ".png"; - thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, fileName + ".png")); + thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, filename)); return filename; } catch (Exception e) @@ -93,7 +93,7 @@ namespace API.Services /// File name with extension of the file. This will always write to public static string WriteCoverThumbnail(Stream stream, string fileName) { - using var thumbnail = NetVips.Image.ThumbnailStream(stream, ThumbnailWidth); + using var thumbnail = Image.ThumbnailStream(stream, ThumbnailWidth); var filename = fileName + ".png"; thumbnail.WriteToFile(Path.Join(DirectoryService.CoverImageDirectory, fileName + ".png")); return filename; diff --git a/API/Services/MetadataService.cs b/API/Services/MetadataService.cs index d443f9f23..acae153c4 100644 --- a/API/Services/MetadataService.cs +++ b/API/Services/MetadataService.cs @@ -1,13 +1,15 @@ -using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading.Tasks; using API.Comparators; +using API.Data.Metadata; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Interfaces; using API.Interfaces.Services; using API.SignalR; @@ -74,7 +76,7 @@ namespace API.Services private string GetCoverImage(MangaFile file, int volumeId, int chapterId) { - file.LastModified = DateTime.Now; + file.UpdateLastModified(); switch (file.Format) { case MangaFormat.Pdf: @@ -102,6 +104,7 @@ namespace API.Services if (ShouldUpdateCoverImage(chapter.CoverImage, firstFile, forceUpdate, chapter.CoverImageLocked)) { + _logger.LogDebug("[MetadataService] Generating cover image for {File}", firstFile?.FilePath); chapter.CoverImage = GetCoverImage(firstFile, chapter.VolumeId, chapter.Id); return true; } @@ -117,8 +120,7 @@ namespace API.Services public bool UpdateMetadata(Volume volume, bool forceUpdate) { // We need to check if Volume coverImage matches first chapters if forceUpdate is false - if (volume == null || !ShouldUpdateCoverImage(volume.CoverImage, null, forceUpdate - , false)) return false; + if (volume == null || !ShouldUpdateCoverImage(volume.CoverImage, null, forceUpdate)) return false; volume.Chapters ??= new List(); var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparerForInChapterSorting).FirstOrDefault(); @@ -137,6 +139,8 @@ namespace API.Services { var madeUpdate = false; if (series == null) return false; + + // NOTE: This will fail if we replace the cover of the first volume on a first scan. Because the series will already have a cover image if (ShouldUpdateCoverImage(series.CoverImage, null, forceUpdate, series.CoverImageLocked)) { series.Volumes ??= new List(); @@ -167,6 +171,9 @@ namespace API.Services private bool UpdateSeriesSummary(Series series, bool forceUpdate) { + // NOTE: This can be problematic when the file changes and a summary already exists, but it is likely + // better to let the user kick off a refresh metadata on an individual Series than having overhead of + // checking File last write time. if (!string.IsNullOrEmpty(series.Summary) && !forceUpdate) return false; var isBook = series.Library.Type == LibraryType.Book; @@ -177,18 +184,21 @@ namespace API.Services if (firstFile == null || (!forceUpdate && !firstFile.HasFileBeenModified())) return false; if (Parser.Parser.IsPdf(firstFile.FilePath)) return false; - if (series.Format is MangaFormat.Archive or MangaFormat.Epub) + var comicInfo = GetComicInfo(series.Format, firstFile); + if (string.IsNullOrEmpty(comicInfo?.Summary)) return false; + + series.Summary = comicInfo.Summary; + return true; + } + + private ComicInfo GetComicInfo(MangaFormat format, MangaFile firstFile) + { + if (format is MangaFormat.Archive or MangaFormat.Epub) { - var summary = Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetSummaryInfo(firstFile.FilePath) : _archiveService.GetSummaryInfo(firstFile.FilePath); - if (!string.IsNullOrEmpty(series.Summary)) - { - series.Summary = summary; - firstFile.LastModified = DateTime.Now; - return true; - } + return Parser.Parser.IsEpub(firstFile.FilePath) ? _bookService.GetComicInfo(firstFile.FilePath) : _archiveService.GetComicInfo(firstFile.FilePath); } - firstFile.LastModified = DateTime.Now; // NOTE: Should I put this here as well since it might not have actually been parsed? - return false; + + return null; } @@ -200,34 +210,65 @@ namespace API.Services /// Force updating cover image even if underlying file has not been modified or chapter already has a cover image public async Task RefreshMetadata(int libraryId, bool forceUpdate = false) { - var sw = Stopwatch.StartNew(); - var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None); + _logger.LogInformation("[MetadataService] Beginning metadata refresh of {LibraryName}", library.Name); - // PERF: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used - _logger.LogInformation("Beginning metadata refresh of {LibraryName}", library.Name); - foreach (var series in library.Series) + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + _logger.LogDebug($"[MetadataService] Refreshing Library {library.Name}. Total Items: {chunkInfo.TotalSize}. Total Chunks: {chunkInfo.TotalChunks} with {chunkInfo.ChunkSize} size."); + + // This technically does + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) { - var volumeUpdated = false; - foreach (var volume in series.Volumes) - { - var chapterUpdated = false; - foreach (var chapter in volume.Chapters) + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + _logger.LogDebug($"[MetadataService] Processing chunk {chunk} / {chunkInfo.TotalChunks} with size {chunkInfo.ChunkSize} Series ({chunk * chunkInfo.ChunkSize} - {(chunk + 1) * chunkInfo.ChunkSize}"); + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, + new UserParams() { - chapterUpdated = UpdateMetadata(chapter, forceUpdate); + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); + _logger.LogDebug($"[MetadataService] Fetched {nonLibrarySeries.Count} series for refresh"); + Parallel.ForEach(nonLibrarySeries, series => + { + _logger.LogDebug("[MetadataService] Processing series {SeriesName}", series.OriginalName); + var volumeUpdated = false; + foreach (var volume in series.Volumes) + { + var chapterUpdated = false; + foreach (var chapter in volume.Chapters) + { + chapterUpdated = UpdateMetadata(chapter, forceUpdate); + } + + volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); } - volumeUpdated = UpdateMetadata(volume, chapterUpdated || forceUpdate); + UpdateMetadata(series, volumeUpdated || forceUpdate); + }); + + if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) + { + _logger.LogInformation( + "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); + + foreach (var series in nonLibrarySeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(library.Id, series.Id)); + } + } + else + { + _logger.LogInformation( + "[MetadataService] Processed {SeriesStart} - {SeriesEnd} out of {TotalSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, chunkInfo.TotalSize, stopwatch.ElapsedMilliseconds, library.Name); } - - UpdateMetadata(series, volumeUpdated || forceUpdate); - _unitOfWork.SeriesRepository.Update(series); } - - if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) - { - _logger.LogInformation("Updated metadata for {LibraryName} in {ElapsedMilliseconds} milliseconds", library.Name, sw.ElapsedMilliseconds); - } + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesNumber} series in library {LibraryName} in {ElapsedMilliseconds} milliseconds total", chunkInfo.TotalSize, library.Name, totalTime); } @@ -239,15 +280,13 @@ namespace API.Services public async Task RefreshMetadataForSeries(int libraryId, int seriesId, bool forceUpdate = false) { var sw = Stopwatch.StartNew(); - var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId); - - var series = library.Series.SingleOrDefault(s => s.Id == seriesId); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); if (series == null) { - _logger.LogError("Series {SeriesId} was not found on Library {LibraryName}", seriesId, libraryId); + _logger.LogError("[MetadataService] Series {SeriesId} was not found on Library {LibraryId}", seriesId, libraryId); return; } - _logger.LogInformation("Beginning metadata refresh of {SeriesName}", series.Name); + _logger.LogInformation("[MetadataService] Beginning metadata refresh of {SeriesName}", series.Name); var volumeUpdated = false; foreach (var volume in series.Volumes) { @@ -261,14 +300,14 @@ namespace API.Services } UpdateMetadata(series, volumeUpdated || forceUpdate); - _unitOfWork.SeriesRepository.Update(series); if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync()) { - _logger.LogInformation("Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.RefreshMetadataEvent(libraryId, seriesId)); + await _messageHub.Clients.All.SendAsync(SignalREvents.RefreshMetadata, MessageFactory.RefreshMetadataEvent(series.LibraryId, series.Id)); } + + _logger.LogInformation("[MetadataService] Updated metadata for {SeriesName} in {ElapsedMilliseconds} milliseconds", series.Name, sw.ElapsedMilliseconds); } } } diff --git a/API/Services/TaskScheduler.cs b/API/Services/TaskScheduler.cs index 2d1b25a7d..e60161b61 100644 --- a/API/Services/TaskScheduler.cs +++ b/API/Services/TaskScheduler.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Threading; using System.Threading.Tasks; using API.Entities.Enums; @@ -52,27 +53,27 @@ namespace API.Services var scanLibrarySetting = setting; _logger.LogDebug("Scheduling Scan Library Task for {Setting}", scanLibrarySetting); RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), - () => CronConverter.ConvertToCronNotation(scanLibrarySetting)); + () => CronConverter.ConvertToCronNotation(scanLibrarySetting), TimeZoneInfo.Local); } else { - RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), Cron.Daily); + RecurringJob.AddOrUpdate("scan-libraries", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); } setting = Task.Run(() => _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.TaskBackup)).Result.Value; if (setting != null) { _logger.LogDebug("Scheduling Backup Task for {Setting}", setting); - RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting)); + RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), () => CronConverter.ConvertToCronNotation(setting), TimeZoneInfo.Local); } else { - RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly); + RecurringJob.AddOrUpdate("backup", () => _backupService.BackupDatabase(), Cron.Weekly, TimeZoneInfo.Local); } - RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily); + RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily, TimeZoneInfo.Local); - RecurringJob.AddOrUpdate("check-for-updates", () => _scannerService.ScanLibraries(), Cron.Daily); + RecurringJob.AddOrUpdate("check-for-updates", () => _scannerService.ScanLibraries(), Cron.Daily, TimeZoneInfo.Local); } #region StatsTasks @@ -88,7 +89,7 @@ namespace API.Services } _logger.LogDebug("Scheduling stat collection daily"); - RecurringJob.AddOrUpdate(SendDataTask, () => _statsService.CollectAndSendStatsData(), Cron.Daily); + RecurringJob.AddOrUpdate(SendDataTask, () => _statsService.CollectAndSendStatsData(), Cron.Daily, TimeZoneInfo.Local); } public void CancelStatsTasks() @@ -111,7 +112,7 @@ namespace API.Services public void ScheduleUpdaterTasks() { _logger.LogInformation("Scheduling Auto-Update tasks"); - RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Weekly); + RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Weekly, TimeZoneInfo.Local); } #endregion @@ -119,7 +120,7 @@ namespace API.Services public void ScanLibrary(int libraryId, bool forceUpdate = false) { _logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId); - BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId, forceUpdate)); + BackgroundJob.Enqueue(() => _scannerService.ScanLibrary(libraryId)); // When we do a scan, force cache to re-unpack in case page numbers change BackgroundJob.Enqueue(() => _cleanupService.CleanupCacheDirectory()); } @@ -141,7 +142,7 @@ namespace API.Services BackgroundJob.Enqueue(() => DirectoryService.ClearDirectory(tempDirectory)); } - public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = false) + public void RefreshSeriesMetadata(int libraryId, int seriesId, bool forceUpdate = true) { _logger.LogInformation("Enqueuing series metadata refresh for: {SeriesId}", seriesId); BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate)); @@ -150,7 +151,7 @@ namespace API.Services public void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false) { _logger.LogInformation("Enqueuing series scan for: {SeriesId}", seriesId); - BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, forceUpdate, CancellationToken.None)); + BackgroundJob.Enqueue(() => _scannerService.ScanSeries(libraryId, seriesId, CancellationToken.None)); } public void BackupDatabase() diff --git a/API/Services/Tasks/BackupService.cs b/API/Services/Tasks/BackupService.cs index 35388985a..b4dc3910b 100644 --- a/API/Services/Tasks/BackupService.cs +++ b/API/Services/Tasks/BackupService.cs @@ -125,7 +125,7 @@ namespace API.Services.Tasks _directoryService.CopyFilesToDirectory( chapterImages.Select(s => Path.Join(DirectoryService.CoverImageDirectory, s)), outputTempDir); } - catch (IOException e) + catch (IOException) { // Swallow exception. This can be a duplicate cover being copied as chapter and volumes can share same file. } diff --git a/API/Services/Tasks/CleanupService.cs b/API/Services/Tasks/CleanupService.cs index c1edf2e6b..93f8ec5db 100644 --- a/API/Services/Tasks/CleanupService.cs +++ b/API/Services/Tasks/CleanupService.cs @@ -1,11 +1,9 @@ using System.IO; -using System.Linq; using System.Threading.Tasks; using API.Interfaces; using API.Interfaces.Services; using Hangfire; using Microsoft.Extensions.Logging; -using NetVips; namespace API.Services.Tasks { diff --git a/API/Services/Tasks/ScannerService.cs b/API/Services/Tasks/ScannerService.cs index 15bb715c7..7f7986eb0 100644 --- a/API/Services/Tasks/ScannerService.cs +++ b/API/Services/Tasks/ScannerService.cs @@ -7,9 +7,11 @@ using System.Threading; using System.Threading.Tasks; using API.Comparators; using API.Data; +using API.Data.Repositories; using API.Entities; using API.Entities.Enums; using API.Extensions; +using API.Helpers; using API.Interfaces; using API.Interfaces.Services; using API.Parser; @@ -46,81 +48,114 @@ namespace API.Services.Tasks [DisableConcurrentExecution(timeoutInSeconds: 360)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanSeries(int libraryId, int seriesId, bool forceUpdate, CancellationToken token) + public async Task ScanSeries(int libraryId, int seriesId, CancellationToken token) { + var sw = new Stopwatch(); var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId); - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId); - var library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId, seriesId); - var dirs = DirectoryService.FindHighestDirectoriesFromFiles(library.Folders.Select(f => f.Path), files.Select(f => f.FilePath).ToList()); - var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new []{ seriesId }); + var series = await _unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); + var chapterIds = await _unitOfWork.SeriesRepository.GetChapterIdsForSeriesAsync(new[] {seriesId}); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); + var folderPaths = library.Folders.Select(f => f.Path).ToList(); + var dirs = DirectoryService.FindHighestDirectoriesFromFiles(folderPaths, files.Select(f => f.FilePath).ToList()); _logger.LogInformation("Beginning file scan on {SeriesName}", series.Name); var scanner = new ParseScannedFiles(_bookService, _logger); var parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles, out var scanElapsedTime); - // If a root level folder scan occurs, then multiple series gets passed in and thus we get a unique constraint issue - // Hence we clear out anything but what we selected for - var firstSeries = library.Series.FirstOrDefault(); + // Remove any parsedSeries keys that don't belong to our series. This can occur when users store 2 series in the same folder + RemoveParsedInfosNotForSeries(parsedSeries, series); + + // If nothing was found, first validate any of the files still exist. If they don't then we have a deletion and can skip the rest of the logic flow + if (parsedSeries.Count == 0) + { + var anyFilesExist = + (await _unitOfWork.SeriesRepository.GetFilesForSeries(series.Id)).Any(m => File.Exists(m.FilePath)); + + if (!anyFilesExist) + { + try + { + _unitOfWork.SeriesRepository.Remove(series); + await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an error during ScanSeries to delete the series"); + await _unitOfWork.RollbackAsync(); + } + + } + else + { + // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, + // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root + // is the series folder. + var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); + if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) + { + dirs = new Dictionary(); + var path = Directory.GetParent(existingFolder)?.FullName; + if (!folderPaths.Contains(path) || !folderPaths.Any(p => p.Contains(path ?? string.Empty))) + { + _logger.LogInformation("[ScanService] Aborted: {SeriesName} has bad naming convention and sits at root of library. Cannot scan series without deletion occuring. Correct file names to have Series Name within it or perform Scan Library", series.OriginalName); + return; + } + if (!string.IsNullOrEmpty(path)) + { + dirs[path] = string.Empty; + } + } + + _logger.LogInformation("{SeriesName} has bad naming convention, forcing rescan at a higher directory", series.OriginalName); + scanner = new ParseScannedFiles(_bookService, _logger); + parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); + totalFiles += totalFiles2; + scanElapsedTime += scanElapsedTime2; + RemoveParsedInfosNotForSeries(parsedSeries, series); + } + } + + // At this point, parsedSeries will have at least one key and we can perform the update. If it still doesn't, just return and don't do anything + if (parsedSeries.Count == 0) return; + + try + { + UpdateSeries(series, parsedSeries); + await CommitAndSend(totalFiles, parsedSeries, sw, scanElapsedTime, series); + } + catch (Exception ex) + { + _logger.LogCritical(ex, "There was an error during ScanSeries to update the series"); + await _unitOfWork.RollbackAsync(); + } + // Tell UI that this series is done + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId, series.Name), + cancellationToken: token); + await CleanupDbEntities(); + BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, series.Id, false)); + } + + private static void RemoveParsedInfosNotForSeries(Dictionary> parsedSeries, Series series) + { var keys = parsedSeries.Keys; - foreach (var key in keys.Where(key => !firstSeries.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || firstSeries?.Format != key.Format)) + foreach (var key in keys.Where(key => + !series.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || series.Format != key.Format)) { parsedSeries.Remove(key); } + } - if (parsedSeries.Count == 0) + private async Task CommitAndSend(int totalFiles, + Dictionary> parsedSeries, Stopwatch sw, long scanElapsedTime, Series series) + { + if (_unitOfWork.HasChanges()) { - // We need to do an additional check for an edge case: If the scan ran and the files do not match the existing Series name, then it is very likely, - // the files have crap naming and if we don't correct, the series will get deleted due to the parser not being able to fallback onto folder parsing as the root - // is the series folder. - var existingFolder = dirs.Keys.FirstOrDefault(key => key.Contains(series.OriginalName)); - if (dirs.Keys.Count == 1 && !string.IsNullOrEmpty(existingFolder)) - { - dirs = new Dictionary(); - var path = Path.GetPathRoot(existingFolder); - if (!string.IsNullOrEmpty(path)) - { - dirs[path] = string.Empty; - } - } - _logger.LogDebug("{SeriesName} has bad naming convention, forcing rescan at a higher directory.", series.OriginalName); - scanner = new ParseScannedFiles(_bookService, _logger); - parsedSeries = scanner.ScanLibrariesForSeries(library.Type, dirs.Keys, out var totalFiles2, out var scanElapsedTime2); - totalFiles += totalFiles2; - scanElapsedTime += scanElapsedTime2; - - // If a root level folder scan occurs, then multiple series gets passed in and thus we get a unique constraint issue - // Hence we clear out anything but what we selected for - firstSeries = library.Series.FirstOrDefault(); - keys = parsedSeries.Keys; - foreach (var key in keys.Where(key => !firstSeries.NameInParserInfo(parsedSeries[key].FirstOrDefault()) || firstSeries?.Format != key.Format)) - { - parsedSeries.Remove(key); - } + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", + totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); } - - var sw = new Stopwatch(); - UpdateLibrary(library, parsedSeries); - - _unitOfWork.LibraryRepository.Update(library); - if (await _unitOfWork.CommitAsync()) - { - _logger.LogInformation( - "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {SeriesName}", - totalFiles, parsedSeries.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, series.Name); - - await CleanupDbEntities(); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadataForSeries(libraryId, seriesId, forceUpdate)); - BackgroundJob.Enqueue(() => _cacheService.CleanupChapters(chapterIds)); - // Tell UI that this series is done - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanSeries, MessageFactory.ScanSeriesEvent(seriesId), cancellationToken: token); - } - else - { - _logger.LogCritical( - "There was a critical error that resulted in a failed scan. Please check logs and rescan"); - await _unitOfWork.RollbackAsync(); - } - } @@ -132,7 +167,7 @@ namespace API.Services.Tasks var libraries = await _unitOfWork.LibraryRepository.GetLibrariesAsync(); foreach (var lib in libraries) { - await ScanLibrary(lib.Id, false); + await ScanLibrary(lib.Id); } _logger.LogInformation("Scan of All Libraries Finished"); } @@ -144,24 +179,26 @@ namespace API.Services.Tasks /// ie) all entities will be rechecked for new cover images and comicInfo.xml changes /// /// - /// [DisableConcurrentExecution(360)] [AutomaticRetry(Attempts = 0, OnAttemptsExceeded = AttemptsExceededAction.Delete)] - public async Task ScanLibrary(int libraryId, bool forceUpdate) + public async Task ScanLibrary(int libraryId) { Library library; try { - library = await _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId); + library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.Folders); } catch (Exception ex) { // This usually only fails if user is not authenticated. - _logger.LogError(ex, "There was an issue fetching Library {LibraryId}", libraryId); + _logger.LogError(ex, "[ScannerService] There was an issue fetching Library {LibraryId}", libraryId); return; } - _logger.LogInformation("Beginning file scan on {LibraryName}", library.Name); + _logger.LogInformation("[ScannerService] Beginning file scan on {LibraryName}", library.Name); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 0)); + var scanner = new ParseScannedFiles(_bookService, _logger); var series = scanner.ScanLibrariesForSeries(library.Type, library.Folders.Select(fp => fp.Path), out var totalFiles, out var scanElapsedTime); @@ -171,25 +208,27 @@ namespace API.Services.Tasks } var sw = Stopwatch.StartNew(); - UpdateLibrary(library, series); + await UpdateLibrary(library, series); + library.LastScanned = DateTime.Now; _unitOfWork.LibraryRepository.Update(library); if (await _unitOfWork.CommitAsync()) { _logger.LogInformation( - "Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", + "[ScannerService] Processed {TotalFiles} files and {ParsedSeriesCount} series in {ElapsedScanTime} milliseconds for {LibraryName}", totalFiles, series.Keys.Count, sw.ElapsedMilliseconds + scanElapsedTime, library.Name); } else { _logger.LogCritical( - "There was a critical error that resulted in a failed scan. Please check logs and rescan"); + "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); } - await CleanupAbandonedChapters(); + await CleanupDbEntities(); - BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, forceUpdate)); - await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibrary, MessageFactory.ScanLibraryEvent(libraryId, "complete")); + BackgroundJob.Enqueue(() => _metadataService.RefreshMetadata(libraryId, false)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(libraryId, 100)); } /// @@ -212,78 +251,171 @@ namespace API.Services.Tasks _logger.LogInformation("Removed {Count} abandoned collection tags", cleanedUp); } - private void UpdateLibrary(Library library, Dictionary> parsedSeries) + private async Task UpdateLibrary(Library library, Dictionary> parsedSeries) { - if (parsedSeries == null) throw new ArgumentNullException(nameof(parsedSeries)); + if (parsedSeries == null) return; - // First, remove any series that are not in parsedSeries list - var missingSeries = FindSeriesNotOnDisk(library.Series, parsedSeries).ToList(); - library.Series = RemoveMissingSeries(library.Series, missingSeries, out var removeCount); - if (removeCount > 0) + // Library contains no Series, so we need to fetch series in groups of ChunkSize + var chunkInfo = await _unitOfWork.SeriesRepository.GetChunkInfo(library.Id); + var stopwatch = Stopwatch.StartNew(); + var totalTime = 0L; + + // Update existing series + _logger.LogDebug("[ScannerService] Updating existing series"); + for (var chunk = 1; chunk <= chunkInfo.TotalChunks; chunk++) { - _logger.LogInformation("Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); - foreach (var s in missingSeries) - { - _logger.LogDebug("Removed {SeriesName} ({Format})", s.Name, s.Format); - } + if (chunkInfo.TotalChunks == 0) continue; + totalTime += stopwatch.ElapsedMilliseconds; + stopwatch.Restart(); + _logger.LogDebug($"[ScannerService] Processing chunk {chunk} / {chunkInfo.TotalChunks} with size {chunkInfo.ChunkSize} Series ({chunk * chunkInfo.ChunkSize} - {(chunk + 1) * chunkInfo.ChunkSize}"); + var nonLibrarySeries = await _unitOfWork.SeriesRepository.GetFullSeriesForLibraryIdAsync(library.Id, new UserParams() + { + PageNumber = chunk, + PageSize = chunkInfo.ChunkSize + }); + + // First, remove any series that are not in parsedSeries list + var missingSeries = FindSeriesNotOnDisk(nonLibrarySeries, parsedSeries).ToList(); + + foreach (var missing in missingSeries) + { + _unitOfWork.SeriesRepository.Remove(missing); + } + + var cleanedSeries = RemoveMissingSeries(nonLibrarySeries, missingSeries, out var removeCount); + if (removeCount > 0) + { + _logger.LogInformation("[ScannerService] Removed {RemoveMissingSeries} series that are no longer on disk:", removeCount); + foreach (var s in missingSeries) + { + _logger.LogDebug("[ScannerService] Removed {SeriesName} ({Format})", s.Name, s.Format); + } + } + + // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series + var librarySeries = cleanedSeries.ToList(); + Parallel.ForEach(librarySeries, (series) => + { + UpdateSeries(series, parsedSeries); + }); + + await _unitOfWork.CommitAsync(); + _logger.LogInformation( + "[ScannerService] Processed {SeriesStart} - {SeriesEnd} series in {ElapsedScanTime} milliseconds for {LibraryName}", + chunk * chunkInfo.ChunkSize, (chunk * chunkInfo.ChunkSize) + nonLibrarySeries.Count, totalTime, library.Name); + + // Emit any series removed + foreach (var missing in missingSeries) + { + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesRemoved, MessageFactory.SeriesRemovedEvent(missing.Id, missing.Name, library.Id)); + } + + var progress = Math.Max(0, Math.Min(100, ((chunk + 1F) * chunkInfo.ChunkSize) / chunkInfo.TotalSize)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); } + // Add new series that have parsedInfos + _logger.LogDebug("[ScannerService] Adding new series"); + var newSeries = new List(); + var allSeries = (await _unitOfWork.SeriesRepository.GetSeriesForLibraryIdAsync(library.Id)).ToList(); foreach (var (key, infos) in parsedSeries) { // Key is normalized already - Series existingSeries; - try - { - existingSeries = library.Series.SingleOrDefault(s => - (s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName) - && (s.Format == key.Format || s.Format == MangaFormat.Unknown)); - } - catch (Exception e) - { - _logger.LogCritical(e, "There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it", key.NormalizedName); - var duplicateSeries = library.Series.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList(); - foreach (var series in duplicateSeries) - { - _logger.LogCritical("{Key} maps with {Series}", key.Name, series.OriginalName); - } + Series existingSeries; + try + { + existingSeries = allSeries.SingleOrDefault(s => + (s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName) + && (s.Format == key.Format || s.Format == MangaFormat.Unknown)); + } + catch (Exception e) + { + _logger.LogCritical(e, "[ScannerService] There are multiple series that map to normalized key {Key}. You can manually delete the entity via UI and rescan to fix it. This will be skipped", key.NormalizedName); + var duplicateSeries = allSeries.Where(s => s.NormalizedName == key.NormalizedName || Parser.Parser.Normalize(s.OriginalName) == key.NormalizedName).ToList(); + foreach (var series in duplicateSeries) + { + _logger.LogCritical("[ScannerService] Duplicate Series Found: {Key} maps with {Series}", key.Name, series.OriginalName); + } - continue; - } - if (existingSeries == null) - { - existingSeries = DbFactory.Series(infos[0].Series); - existingSeries.Format = key.Format; - library.Series.Add(existingSeries); - } + continue; + } - existingSeries.NormalizedName = Parser.Parser.Normalize(existingSeries.Name); - existingSeries.OriginalName ??= infos[0].Series; - existingSeries.Metadata ??= DbFactory.SeriesMetadata(new List()); - existingSeries.Format = key.Format; + if (existingSeries != null) continue; + + existingSeries = DbFactory.Series(infos[0].Series); + existingSeries.Format = key.Format; + newSeries.Add(existingSeries); } - // Now, we only have to deal with series that exist on disk. Let's recalculate the volumes for each series - var librarySeries = library.Series.ToList(); - Parallel.ForEach(librarySeries, (series) => + var i = 0; + foreach(var series in newSeries) { - try - { - _logger.LogInformation("Processing series {SeriesName}", series.OriginalName); - UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray()); - series.Pages = series.Volumes.Sum(v => v.Pages); - } - catch (Exception ex) - { - _logger.LogError(ex, "There was an exception updating volumes for {SeriesName}", series.Name); - } - }); + try + { + _logger.LogDebug("[ScannerService] Processing series {SeriesName}", series.OriginalName); + UpdateVolumes(series, ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray()); + series.Pages = series.Volumes.Sum(v => v.Pages); + series.LibraryId = library.Id; // We have to manually set this since we aren't adding the series to the Library's series. + _unitOfWork.SeriesRepository.Attach(series); + if (await _unitOfWork.CommitAsync()) + { + _logger.LogInformation( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); - // Last step, remove any series that have no pages - library.Series = library.Series.Where(s => s.Pages > 0).ToList(); + // Inform UI of new series added + await _messageHub.Clients.All.SendAsync(SignalREvents.SeriesAdded, MessageFactory.SeriesAddedEvent(series.Id, series.Name, library.Id)); + var progress = Math.Max(0F, Math.Min(100F, i * 1F / newSeries.Count)); + await _messageHub.Clients.All.SendAsync(SignalREvents.ScanLibraryProgress, + MessageFactory.ScanLibraryProgressEvent(library.Id, progress)); + } + else + { + // This is probably not needed. Better to catch the exception. + _logger.LogCritical( + "[ScannerService] There was a critical error that resulted in a failed scan. Please check logs and rescan"); + } + + i++; + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); + } + } + + _logger.LogDebug( + "[ScannerService] Added {NewSeries} series in {ElapsedScanTime} milliseconds for {LibraryName}", + newSeries.Count, stopwatch.ElapsedMilliseconds, library.Name); } - public IEnumerable FindSeriesNotOnDisk(ICollection existingSeries, Dictionary> parsedSeries) + private void UpdateSeries(Series series, Dictionary> parsedSeries) + { + try + { + _logger.LogInformation("[ScannerService] Processing series {SeriesName}", series.OriginalName); + + var parsedInfos = ParseScannedFiles.GetInfosByName(parsedSeries, series).ToArray(); + UpdateVolumes(series, parsedInfos); + series.Pages = series.Volumes.Sum(v => v.Pages); + + series.NormalizedName = Parser.Parser.Normalize(series.Name); + series.Metadata ??= DbFactory.SeriesMetadata(new List()); + if (series.Format == MangaFormat.Unknown) + { + series.Format = parsedInfos[0].Format; + } + series.OriginalName ??= parsedInfos[0].Series; + } + catch (Exception ex) + { + _logger.LogError(ex, "[ScannerService] There was an exception updating volumes for {SeriesName}", series.Name); + } + } + + public static IEnumerable FindSeriesNotOnDisk(IEnumerable existingSeries, Dictionary> parsedSeries) { var foundSeries = parsedSeries.Select(s => s.Key.Name).ToList(); return existingSeries.Where(es => !es.NameInList(foundSeries) && !SeriesHasMatchingParserInfoFormat(es, parsedSeries)); @@ -332,7 +464,7 @@ namespace API.Services.Tasks /// Series not found on disk or can't be parsed /// /// the updated existingSeries - public static ICollection RemoveMissingSeries(ICollection existingSeries, IEnumerable missingSeries, out int removeCount) + public static IList RemoveMissingSeries(IList existingSeries, IEnumerable missingSeries, out int removeCount) { var existingCount = existingSeries.Count; var missingList = missingSeries.ToList(); @@ -351,7 +483,7 @@ namespace API.Services.Tasks var startingVolumeCount = series.Volumes.Count; // Add new volumes and update chapters per volume var distinctVolumes = parsedInfos.DistinctVolumes(); - _logger.LogDebug("Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); + _logger.LogDebug("[ScannerService] Updating {DistinctVolumes} volumes on {SeriesName}", distinctVolumes.Count, series.Name); foreach (var volumeNumber in distinctVolumes) { var volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber); @@ -359,9 +491,10 @@ namespace API.Services.Tasks { volume = DbFactory.Volume(volumeNumber); series.Volumes.Add(volume); + _unitOfWork.VolumeRepository.Add(volume); } - _logger.LogDebug("Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); + _logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name); var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray(); UpdateChapters(volume, infos); volume.Pages = volume.Chapters.Sum(c => c.Pages); @@ -371,23 +504,26 @@ namespace API.Services.Tasks var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList(); if (series.Volumes.Count != nonDeletedVolumes.Count) { - _logger.LogDebug("Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", + _logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name", (series.Volumes.Count - nonDeletedVolumes.Count), series.Name); var deletedVolumes = series.Volumes.Except(nonDeletedVolumes); foreach (var volume in deletedVolumes) { - var file = volume.Chapters.FirstOrDefault()?.Files.FirstOrDefault()?.FilePath ?? "no files"; - if (new FileInfo(file).Exists) - { - _logger.LogError("Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", file); - } - _logger.LogDebug("Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); + var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? ""; + if (!string.IsNullOrEmpty(file) && File.Exists(file)) + { + _logger.LogError( + "[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}", + file); + } + + _logger.LogDebug("[ScannerService] Removed {SeriesName} - Volume {Volume}: {File}", series.Name, volume.Name, file); } series.Volumes = nonDeletedVolumes; } - _logger.LogDebug("Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", + _logger.LogDebug("[ScannerService] Updated {SeriesName} volumes from {StartingVolumeCount} to {VolumeCount}", series.Name, startingVolumeCount, series.Volumes.Count); } @@ -417,7 +553,7 @@ namespace API.Services.Tasks if (chapter == null) { _logger.LogDebug( - "Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); + "[ScannerService] Adding new chapter, {Series} - Vol {Volume} Ch {Chapter}", info.Series, info.Volumes, info.Chapters); volume.Chapters.Add(DbFactory.Chapter(info)); } else @@ -454,7 +590,7 @@ namespace API.Services.Tasks { if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter)) { - _logger.LogDebug("Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); + _logger.LogDebug("[ScannerService] Removed chapter {Chapter} for Volume {VolumeNumber} on {SeriesName}", existingChapter.Range, volume.Name, parsedInfos[0].Series); volume.Chapters.Remove(existingChapter); } else @@ -470,42 +606,47 @@ namespace API.Services.Tasks private MangaFile CreateMangaFile(ParserInfo info) { - switch (info.Format) + MangaFile mangaFile = null; + switch (info.Format) { case MangaFormat.Archive: { - return new MangaFile() + mangaFile = new MangaFile() { FilePath = info.FullFilePath, Format = info.Format, Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath) }; + break; } case MangaFormat.Pdf: case MangaFormat.Epub: { - return new MangaFile() + mangaFile = new MangaFile() { FilePath = info.FullFilePath, Format = info.Format, Pages = _bookService.GetNumberOfPages(info.FullFilePath) }; + break; } case MangaFormat.Image: { - return new MangaFile() - { - FilePath = info.FullFilePath, - Format = info.Format, - Pages = 1 - }; + mangaFile = new MangaFile() + { + FilePath = info.FullFilePath, + Format = info.Format, + Pages = 1 + }; + break; } default: _logger.LogWarning("[Scanner] Ignoring {Filename}. File type is not supported", info.Filename); break; } - return null; + mangaFile?.UpdateLastModified(); + return mangaFile; } private void AddOrUpdateFileForChapter(Chapter chapter, ParserInfo info) @@ -515,20 +656,31 @@ namespace API.Services.Tasks if (existingFile != null) { existingFile.Format = info.Format; - if (existingFile.HasFileBeenModified() || existingFile.Pages == 0) + if (!existingFile.HasFileBeenModified() && existingFile.Pages != 0) return; + switch (existingFile.Format) { - existingFile.Pages = (existingFile.Format == MangaFormat.Epub || existingFile.Format == MangaFormat.Pdf) - ? _bookService.GetNumberOfPages(info.FullFilePath) - : _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + case MangaFormat.Epub: + case MangaFormat.Pdf: + existingFile.Pages = _bookService.GetNumberOfPages(info.FullFilePath); + break; + case MangaFormat.Image: + existingFile.Pages = 1; + break; + case MangaFormat.Unknown: + existingFile.Pages = 0; + break; + case MangaFormat.Archive: + existingFile.Pages = _archiveService.GetNumberOfPagesFromArchive(info.FullFilePath); + break; } + existingFile.LastModified = File.GetLastWriteTime(info.FullFilePath); } else { var file = CreateMangaFile(info); - if (file != null) - { - chapter.Files.Add(file); - } + if (file == null) return; + + chapter.Files.Add(file); } } } diff --git a/API/SignalR/MessageFactory.cs b/API/SignalR/MessageFactory.cs index ad6eed5c9..19fd66f55 100644 --- a/API/SignalR/MessageFactory.cs +++ b/API/SignalR/MessageFactory.cs @@ -1,23 +1,52 @@ -using System.Threading; +using System; using API.DTOs.Update; namespace API.SignalR { public static class MessageFactory { - public static SignalRMessage ScanSeriesEvent(int seriesId) + public static SignalRMessage ScanSeriesEvent(int seriesId, string seriesName) { return new SignalRMessage() { Name = SignalREvents.ScanSeries, Body = new { - SeriesId = seriesId + SeriesId = seriesId, + SeriesName = seriesName } }; } - public static SignalRMessage ScanLibraryEvent(int libraryId, string stage) + public static SignalRMessage SeriesAddedEvent(int seriesId, string seriesName, int libraryId) + { + return new SignalRMessage() + { + Name = SignalREvents.SeriesAdded, + Body = new + { + SeriesId = seriesId, + SeriesName = seriesName, + LibraryId = libraryId + } + }; + } + + public static SignalRMessage SeriesRemovedEvent(int seriesId, string seriesName, int libraryId) + { + return new SignalRMessage() + { + Name = SignalREvents.SeriesRemoved, + Body = new + { + SeriesId = seriesId, + SeriesName = seriesName, + LibraryId = libraryId + } + }; + } + + public static SignalRMessage ScanLibraryProgressEvent(int libraryId, float progress) { return new SignalRMessage() { @@ -25,11 +54,14 @@ namespace API.SignalR Body = new { LibraryId = libraryId, - Stage = stage + Progress = progress, + EventTime = DateTime.Now } }; } + + public static SignalRMessage RefreshMetadataEvent(int libraryId, int seriesId) { return new SignalRMessage() @@ -52,5 +84,17 @@ namespace API.SignalR }; } + public static SignalRMessage SeriesAddedToCollection(int tagId, int seriesId) + { + return new SignalRMessage + { + Name = SignalREvents.UpdateVersion, + Body = new + { + TagId = tagId, + SeriesId = seriesId + } + }; + } } } diff --git a/API/SignalR/MessageHub.cs b/API/SignalR/MessageHub.cs index 262e5b3bb..2b3cd96cc 100644 --- a/API/SignalR/MessageHub.cs +++ b/API/SignalR/MessageHub.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using API.Extensions; +using API.SignalR.Presence; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.SignalR; @@ -13,8 +15,14 @@ namespace API.SignalR [Authorize] public class MessageHub : Hub { + private readonly IPresenceTracker _tracker; private static readonly HashSet Connections = new HashSet(); + public MessageHub(IPresenceTracker tracker) + { + _tracker = tracker; + } + public static bool IsConnected { get @@ -33,6 +41,12 @@ namespace API.SignalR Connections.Add(Context.ConnectionId); } + await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); + + var currentUsers = await PresenceTracker.GetOnlineUsers(); + await Clients.All.SendAsync(SignalREvents.OnlineUsers, currentUsers); + + await base.OnConnectedAsync(); } @@ -43,6 +57,12 @@ namespace API.SignalR Connections.Remove(Context.ConnectionId); } + await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); + + var currentUsers = await PresenceTracker.GetOnlineUsers(); + await Clients.All.SendAsync(SignalREvents.OnlineUsers, currentUsers); + + await base.OnDisconnectedAsync(exception); } } diff --git a/API/SignalR/PresenceHub.cs b/API/SignalR/PresenceHub.cs deleted file mode 100644 index bb700e88a..000000000 --- a/API/SignalR/PresenceHub.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.Threading.Tasks; -using API.Extensions; -using API.SignalR.Presence; -using Microsoft.AspNetCore.SignalR; - -namespace API.SignalR -{ - /// - /// Keeps track of who is logged into the app - /// - public class PresenceHub : Hub - { - private readonly IPresenceTracker _tracker; - - public PresenceHub(IPresenceTracker tracker) - { - _tracker = tracker; - } - - public override async Task OnConnectedAsync() - { - await _tracker.UserConnected(Context.User.GetUsername(), Context.ConnectionId); - - var currentUsers = await PresenceTracker.GetOnlineUsers(); - await Clients.All.SendAsync("GetOnlineUsers", currentUsers); - - - } - - public override async Task OnDisconnectedAsync(Exception exception) - { - await _tracker.UserDisconnected(Context.User.GetUsername(), Context.ConnectionId); - - var currentUsers = await PresenceTracker.GetOnlineUsers(); - await Clients.All.SendAsync("GetOnlineUsers", currentUsers); - - await base.OnDisconnectedAsync(exception); - } - } -} diff --git a/API/SignalR/SignalREvents.cs b/API/SignalR/SignalREvents.cs index fcd077146..d0ce5102e 100644 --- a/API/SignalR/SignalREvents.cs +++ b/API/SignalR/SignalREvents.cs @@ -6,6 +6,10 @@ public const string ScanSeries = "ScanSeries"; public const string RefreshMetadata = "RefreshMetadata"; public const string ScanLibrary = "ScanLibrary"; - + public const string SeriesAdded = "SeriesAdded"; + public const string SeriesRemoved = "SeriesRemoved"; + public const string ScanLibraryProgress = "ScanLibraryProgress"; + public const string OnlineUsers = "OnlineUsers"; + public const string SeriesAddedToCollection = "SeriesAddedToCollection"; } } diff --git a/API/Startup.cs b/API/Startup.cs index ee26e2d2b..e04d61279 100644 --- a/API/Startup.cs +++ b/API/Startup.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Net; using System.Net.Sockets; using API.Extensions; +using API.Interfaces; +using API.Interfaces.Repositories; using API.Middleware; using API.Services; using API.Services.HostedServices; @@ -52,8 +54,41 @@ namespace API services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new OpenApiInfo { Title = "Kavita API", Version = "v1" }); + + c.SwaggerDoc("Kavita API", new OpenApiInfo() + { + Description = "Kavita provides a set of APIs that are authenticated by JWT. JWT token can be copied from local storage.", + Title = "Kavita API", + Version = "v1", + }); + var filePath = Path.Combine(AppContext.BaseDirectory, "API.xml"); c.IncludeXmlComments(filePath); + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme { + In = ParameterLocation.Header, + Description = "Please insert JWT with Bearer into field", + Name = "Authorization", + Type = SecuritySchemeType.ApiKey + }); + c.AddSecurityRequirement(new OpenApiSecurityRequirement { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + + c.AddServer(new OpenApiServer() + { + Description = "Local Server", + Url = "http://localhost:5000/", + }); }); services.AddResponseCompression(options => { @@ -88,14 +123,17 @@ namespace API // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. public void Configure(IApplicationBuilder app, IBackgroundJobClient backgroundJobs, IWebHostEnvironment env, - IHostApplicationLifetime applicationLifetime) + IHostApplicationLifetime applicationLifetime, IServiceProvider serviceProvider) { app.UseMiddleware(); if (env.IsDevelopment()) { app.UseSwagger(); - app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", "API v1")); + app.UseSwaggerUI(c => + { + c.SwaggerEndpoint("/swagger/v1/swagger.json", "Kavita API " + BuildInfo.Version); + }); app.UseHangfireDashboard(); } @@ -124,11 +162,29 @@ namespace API app.UseDefaultFiles(); + // This is not implemented completely. Commenting out until implemented + // var service = serviceProvider.GetRequiredService(); + // var settings = service.SettingsRepository.GetSettingsDto(); + // if (!string.IsNullOrEmpty(settings.BaseUrl) && !settings.BaseUrl.Equals("/")) + // { + // var path = !settings.BaseUrl.StartsWith("/") + // ? $"/{settings.BaseUrl}" + // : settings.BaseUrl; + // path = !path.EndsWith("/") + // ? $"{path}/" + // : path; + // app.UsePathBase(path); + // Console.WriteLine("Starting with base url as " + path); + // } + app.UseStaticFiles(new StaticFileOptions { ContentTypeProvider = new FileExtensionContentTypeProvider() }); + + + app.Use(async (context, next) => { context.Response.GetTypedHeaders().CacheControl = @@ -147,7 +203,6 @@ namespace API { endpoints.MapControllers(); endpoints.MapHub("hubs/messages"); - endpoints.MapHub("hubs/presence"); endpoints.MapHangfireDashboard(); endpoints.MapFallbackToController("Index", "Fallback"); }); diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 83192fafd..26ca78d92 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -4,15 +4,14 @@ net5.0 kavitareader.com Kavita - 0.4.6.1 + 0.4.7.0 en - - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/README.md b/README.md index 16ca17853..0cccdd53b 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ Password: Demouser64 - Linux users must ensure the directory & kavita.db is writable by Kavita (might require starting server once) - Run Kavita - If you are updating, do not copy appsettings.json from the new version over. It will override your TokenKey and you will have to reauthenticate on your devices. - +- Open localhost:5000 and setup your account and libraries in the UI. ### Docker Running your Kavita server in docker is super easy! Barely an inconvenience. You can run it with this command: diff --git a/UI/Web/package-lock.json b/UI/Web/package-lock.json index 2ae36e55d..c69cf5812 100644 --- a/UI/Web/package-lock.json +++ b/UI/Web/package-lock.json @@ -2679,135 +2679,6 @@ } } }, - "@sentry/angular": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/angular/-/angular-6.10.0.tgz", - "integrity": "sha512-SSnsz4sVu9LJh7RM+z9FopWytl2yYNZQ2nK/zv/6iQKIBOqvnCqUIPjVjq1rFYXOe0jOJKsn0QlQLKp4MajYMg==", - "requires": { - "@sentry/browser": "6.10.0", - "@sentry/types": "6.10.0", - "@sentry/utils": "6.10.0", - "rxjs": "^6.6.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@sentry/browser": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-6.10.0.tgz", - "integrity": "sha512-H0Blgp8f8bomebkkGWIgxHVjabtQAlsKJDiFXBg7gIc75YcarRxwH0R3hMog1/h8mmv4CGGUsy5ljYW6jsNnvA==", - "requires": { - "@sentry/core": "6.10.0", - "@sentry/types": "6.10.0", - "@sentry/utils": "6.10.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@sentry/core": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-6.10.0.tgz", - "integrity": "sha512-5KlxHJlbD7AMo+b9pMGkjxUOfMILtsqCtGgI7DMvZNfEkdohO8QgUY+hPqr540kmwArFS91ipQYWhqzGaOhM3Q==", - "requires": { - "@sentry/hub": "6.10.0", - "@sentry/minimal": "6.10.0", - "@sentry/types": "6.10.0", - "@sentry/utils": "6.10.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@sentry/hub": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-6.10.0.tgz", - "integrity": "sha512-MV8wjhWiFAXZAhmj7Ef5QdBr2IF93u8xXiIo2J+dRZ7eVa4/ZszoUiDbhUcl/TPxczaw4oW2a6tINBNFLzXiig==", - "requires": { - "@sentry/types": "6.10.0", - "@sentry/utils": "6.10.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@sentry/integrations": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/integrations/-/integrations-6.10.0.tgz", - "integrity": "sha512-NMtB0jjFYFZRxyjYu2dWLThk9YPIwqhi4hYywmWkbv4/ILzi5Rwnh+aqNW6yrj8qG4b9itNMh3YvEzmf0aqauw==", - "requires": { - "@sentry/types": "6.10.0", - "@sentry/utils": "6.10.0", - "localforage": "^1.8.1", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@sentry/minimal": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-6.10.0.tgz", - "integrity": "sha512-yarm046UgUFIBoxqnBan2+BEgaO9KZCrLzsIsmALiQvpfW92K1lHurSawl5W6SR7wCYBnNn7CPvPE/BHFdy4YA==", - "requires": { - "@sentry/hub": "6.10.0", - "@sentry/types": "6.10.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, - "@sentry/types": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/types/-/types-6.10.0.tgz", - "integrity": "sha512-M7s0JFgG7/6/yNVYoPUbxzaXDhnzyIQYRRJJKRaTD77YO4MHvi4Ke8alBWqD5fer0cPIfcSkBqa9BLdqRqcMWw==" - }, - "@sentry/utils": { - "version": "6.10.0", - "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-6.10.0.tgz", - "integrity": "sha512-F9OczOcZMFtazYVZ6LfRIe65/eOfQbiAedIKS0li4npuMz0jKYRbxrjd/U7oLiNQkPAp4/BujU4m1ZIwq6a+tg==", - "requires": { - "@sentry/types": "6.10.0", - "tslib": "^1.9.3" - }, - "dependencies": { - "tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" - } - } - }, "@sinonjs/commons": { "version": "1.8.2", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.2.tgz", @@ -7521,7 +7392,8 @@ "immediate": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", - "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=" + "integrity": "sha1-nbHb0Pr43m++D13V5Wu2BigN5ps=", + "dev": true }, "import-fresh": { "version": "2.0.0", @@ -10032,24 +9904,6 @@ "json5": "^2.1.2" } }, - "localforage": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.9.0.tgz", - "integrity": "sha512-rR1oyNrKulpe+VM9cYmcFn6tsHuokyVHFaCM3+osEmxaHTbEk8oQu6eGDfS6DQLWi/N67XRmB8ECG37OES368g==", - "requires": { - "lie": "3.1.1" - }, - "dependencies": { - "lie": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", - "integrity": "sha1-mkNrLMd0bKWd56QfpGmz77dr2H4=", - "requires": { - "immediate": "~3.0.5" - } - } - } - }, "locate-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", diff --git a/UI/Web/package.json b/UI/Web/package.json index 515c35df8..c54eee713 100644 --- a/UI/Web/package.json +++ b/UI/Web/package.json @@ -31,8 +31,6 @@ "@ng-bootstrap/ng-bootstrap": "^9.1.0", "@ngx-lite/nav-drawer": "^0.4.6", "@ngx-lite/util": "0.0.0", - "@sentry/angular": "^6.10.0", - "@sentry/integrations": "^6.10.0", "@types/file-saver": "^2.0.1", "angular-ng-autocomplete": "^2.0.5", "bootstrap": "^4.5.0", diff --git a/UI/Web/src/app/_models/config-data.ts b/UI/Web/src/app/_models/config-data.ts new file mode 100644 index 000000000..360fc45b1 --- /dev/null +++ b/UI/Web/src/app/_models/config-data.ts @@ -0,0 +1,10 @@ +/** + * This is for base url only. Not to be used my applicaiton, only loading and bootstrapping app + */ +export class ConfigData { + baseUrl: string = '/'; + + constructor(baseUrl: string) { + this.baseUrl = baseUrl; + } +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/refresh-metadata-event.ts b/UI/Web/src/app/_models/events/refresh-metadata-event.ts new file mode 100644 index 000000000..51fda3301 --- /dev/null +++ b/UI/Web/src/app/_models/events/refresh-metadata-event.ts @@ -0,0 +1,4 @@ +export interface RefreshMetadataEvent { + libraryId: number; + seriesId: number; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/scan-library-progress-event.ts b/UI/Web/src/app/_models/events/scan-library-progress-event.ts new file mode 100644 index 000000000..e8460fde4 --- /dev/null +++ b/UI/Web/src/app/_models/events/scan-library-progress-event.ts @@ -0,0 +1,5 @@ +export interface ScanLibraryProgressEvent { + libraryId: number; + progress: number; + eventTime: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/scan-series-event.ts b/UI/Web/src/app/_models/events/scan-series-event.ts index 45f7a07bc..f60d82e17 100644 --- a/UI/Web/src/app/_models/events/scan-series-event.ts +++ b/UI/Web/src/app/_models/events/scan-series-event.ts @@ -1,3 +1,4 @@ export interface ScanSeriesEvent { seriesId: number; + seriesName: string; } \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/series-added-event.ts b/UI/Web/src/app/_models/events/series-added-event.ts new file mode 100644 index 000000000..3e9c5af47 --- /dev/null +++ b/UI/Web/src/app/_models/events/series-added-event.ts @@ -0,0 +1,5 @@ +export interface SeriesAddedEvent { + libraryId: number; + seriesId: number; + seriesName: string; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/events/series-added-to-collection-event.ts b/UI/Web/src/app/_models/events/series-added-to-collection-event.ts new file mode 100644 index 000000000..d07add15f --- /dev/null +++ b/UI/Web/src/app/_models/events/series-added-to-collection-event.ts @@ -0,0 +1,4 @@ +export interface SeriesAddedToCollectionEvent { + tagId: number; + seriesId: number; +} \ No newline at end of file diff --git a/UI/Web/src/app/_models/library.ts b/UI/Web/src/app/_models/library.ts index 5b9f8ec28..01a5727ae 100644 --- a/UI/Web/src/app/_models/library.ts +++ b/UI/Web/src/app/_models/library.ts @@ -7,7 +7,7 @@ export enum LibraryType { export interface Library { id: number; name: string; - coverImage: string; + lastScanned: string; type: LibraryType; folders: string[]; } \ No newline at end of file diff --git a/UI/Web/src/app/_services/account.service.ts b/UI/Web/src/app/_services/account.service.ts index bc6c762b4..d3006815a 100644 --- a/UI/Web/src/app/_services/account.service.ts +++ b/UI/Web/src/app/_services/account.service.ts @@ -5,10 +5,8 @@ import { map, takeUntil } from 'rxjs/operators'; import { environment } from 'src/environments/environment'; import { Preferences } from '../_models/preferences/preferences'; import { User } from '../_models/user'; -import * as Sentry from "@sentry/angular"; import { Router } from '@angular/router'; import { MessageHubService } from './message-hub.service'; -import { PresenceHubService } from './presence-hub.service'; @Injectable({ providedIn: 'root' @@ -17,6 +15,7 @@ export class AccountService implements OnDestroy { baseUrl = environment.apiUrl; userKey = 'kavita-user'; + public lastLoginKey = 'kavita-lastlogin'; currentUser: User | undefined; // Stores values, when someone subscribes gives (1) of last values seen. @@ -26,7 +25,7 @@ export class AccountService implements OnDestroy { private readonly onDestroy = new Subject(); constructor(private httpClient: HttpClient, private router: Router, - private messageHub: MessageHubService, private presenceHub: PresenceHubService) {} + private messageHub: MessageHubService) {} ngOnDestroy(): void { this.onDestroy.next(); @@ -51,8 +50,7 @@ export class AccountService implements OnDestroy { const user = response; if (user) { this.setCurrentUser(user); - this.messageHub.createHubConnection(user); - this.presenceHub.createHubConnection(user); + this.messageHub.createHubConnection(user, this.hasAdminRole(user)); } }), takeUntil(this.onDestroy) @@ -64,14 +62,9 @@ export class AccountService implements OnDestroy { user.roles = []; const roles = this.getDecodedToken(user.token).role; Array.isArray(roles) ? user.roles = roles : user.roles.push(roles); - Sentry.setContext('admin', {'admin': this.hasAdminRole(user)}); - Sentry.configureScope(scope => { - scope.setUser({ - username: user.username - }); - }); localStorage.setItem(this.userKey, JSON.stringify(user)); + localStorage.setItem(this.lastLoginKey, user.username); } this.currentUserSource.next(user); @@ -85,7 +78,6 @@ export class AccountService implements OnDestroy { // Upon logout, perform redirection this.router.navigateByUrl('/login'); this.messageHub.stopHubConnection(); - this.presenceHub.stopHubConnection(); } register(model: {username: string, password: string, isAdmin?: boolean}) { diff --git a/UI/Web/src/app/_services/action.service.ts b/UI/Web/src/app/_services/action.service.ts index 0cdfb3cfe..3d654108f 100644 --- a/UI/Web/src/app/_services/action.service.ts +++ b/UI/Web/src/app/_services/action.service.ts @@ -6,6 +6,7 @@ import { take } from 'rxjs/operators'; import { BookmarksModalComponent } from '../cards/_modals/bookmarks-modal/bookmarks-modal.component'; import { AddToListModalComponent, ADD_FLOW } from '../reading-list/_modals/add-to-list-modal/add-to-list-modal.component'; import { EditReadingListModalComponent } from '../reading-list/_modals/edit-reading-list-modal/edit-reading-list-modal.component'; +import { ConfirmService } from '../shared/confirm.service'; import { Chapter } from '../_models/chapter'; import { Library } from '../_models/library'; import { ReadingList } from '../_models/reading-list'; @@ -35,7 +36,8 @@ export class ActionService implements OnDestroy { private readingListModalRef: NgbModalRef | null = null; constructor(private libraryService: LibraryService, private seriesService: SeriesService, - private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal) { } + private readerService: ReaderService, private toastr: ToastrService, private modalService: NgbModal, + private confirmService: ConfirmService) { } ngOnDestroy() { this.onDestroy.next(); @@ -66,11 +68,15 @@ export class ActionService implements OnDestroy { * @param callback Optional callback to perform actions after API completes * @returns */ - refreshMetadata(library: Partial, callback?: LibraryActionCallback) { + async refreshMetadata(library: Partial, callback?: LibraryActionCallback) { if (!library.hasOwnProperty('id') || library.id === undefined) { return; } + if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + return; + } + this.libraryService.refreshMetadata(library?.id).pipe(take(1)).subscribe((res: any) => { this.toastr.success('Scan started for ' + library.name); if (callback) { @@ -128,7 +134,11 @@ export class ActionService implements OnDestroy { * @param series Series, must have libraryId, id and name populated * @param callback Optional callback to perform actions after API completes */ - refreshMetdata(series: Series, callback?: SeriesActionCallback) { + async refreshMetdata(series: Series, callback?: SeriesActionCallback) { + if (!await this.confirmService.confirm('Refresh metadata will force all cover images and metadata to be recalculated. This is a heavy operation. Are you sure you don\'t want to perform a Scan instead?')) { + return; + } + this.seriesService.refreshMetadata(series).pipe(take(1)).subscribe((res: any) => { this.toastr.success('Refresh started for ' + series.name); if (callback) { @@ -235,10 +245,10 @@ export class ActionService implements OnDestroy { markMultipleAsUnread(seriesId: number, volumes: Array, chapters?: Array, callback?: VoidActionCallback) { this.readerService.markMultipleUnread(seriesId, volumes.map(v => v.id), chapters?.map(c => c.id)).pipe(take(1)).subscribe(() => { volumes.forEach(volume => { - volume.pagesRead = volume.pages; - volume.chapters?.forEach(c => c.pagesRead = c.pages); + volume.pagesRead = 0; + volume.chapters?.forEach(c => c.pagesRead = 0); }); - chapters?.forEach(c => c.pagesRead = c.pages); + chapters?.forEach(c => c.pagesRead = 0); this.toastr.success('Marked as Read'); if (callback) { diff --git a/UI/Web/src/app/_services/member.service.ts b/UI/Web/src/app/_services/member.service.ts index 519807bb9..3e59347f7 100644 --- a/UI/Web/src/app/_services/member.service.ts +++ b/UI/Web/src/app/_services/member.service.ts @@ -16,6 +16,10 @@ export class MemberService { return this.httpClient.get(this.baseUrl + 'users'); } + getMemberNames() { + return this.httpClient.get(this.baseUrl + 'users/names'); + } + adminExists() { return this.httpClient.get(this.baseUrl + 'admin/exists'); } diff --git a/UI/Web/src/app/_services/message-hub.service.ts b/UI/Web/src/app/_services/message-hub.service.ts index f5d193f6a..dc7490d74 100644 --- a/UI/Web/src/app/_services/message-hub.service.ts +++ b/UI/Web/src/app/_services/message-hub.service.ts @@ -1,18 +1,24 @@ import { EventEmitter, Injectable } from '@angular/core'; import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'; -import { User } from '@sentry/angular'; +import { ToastrService } from 'ngx-toastr'; import { BehaviorSubject, ReplaySubject } from 'rxjs'; import { environment } from 'src/environments/environment'; import { UpdateNotificationModalComponent } from '../shared/update-notification/update-notification-modal.component'; -import { ScanLibraryEvent } from '../_models/events/scan-library-event'; +import { RefreshMetadataEvent } from '../_models/events/refresh-metadata-event'; +import { ScanLibraryProgressEvent } from '../_models/events/scan-library-progress-event'; import { ScanSeriesEvent } from '../_models/events/scan-series-event'; +import { SeriesAddedEvent } from '../_models/events/series-added-event'; +import { User } from '../_models/user'; export enum EVENTS { UpdateAvailable = 'UpdateAvailable', ScanSeries = 'ScanSeries', - ScanLibrary = 'ScanLibrary', RefreshMetadata = 'RefreshMetadata', + SeriesAdded = 'SeriesAdded', + ScanLibraryProgress = 'ScanLibraryProgress', + OnlineUsers = 'OnlineUsers', + SeriesAddedToCollection = 'SeriesAddedToCollection' } export interface Message { @@ -31,12 +37,23 @@ export class MessageHubService { private messagesSource = new ReplaySubject>(1); public messages$ = this.messagesSource.asObservable(); + private onlineUsersSource = new BehaviorSubject([]); + onlineUsers$ = this.onlineUsersSource.asObservable(); + public scanSeries: EventEmitter = new EventEmitter(); - public scanLibrary: EventEmitter = new EventEmitter(); + public scanLibrary: EventEmitter = new EventEmitter(); + public seriesAdded: EventEmitter = new EventEmitter(); + public refreshMetadata: EventEmitter = new EventEmitter(); - constructor(private modalService: NgbModal) { } + isAdmin: boolean = false; + + constructor(private modalService: NgbModal, private toastr: ToastrService) { + + } + + createHubConnection(user: User, isAdmin: boolean) { + this.isAdmin = isAdmin; - createHubConnection(user: User) { this.hubConnection = new HubConnectionBuilder() .withUrl(this.hubUrl + 'messages', { accessTokenFactory: () => user.token @@ -48,10 +65,11 @@ export class MessageHubService { .start() .catch(err => console.error(err)); - this.hubConnection.on('receiveMessage', body => { - //console.log('[Hub] Body: ', body); + this.hubConnection.on(EVENTS.OnlineUsers, (usernames: string[]) => { + this.onlineUsersSource.next(usernames); }); + this.hubConnection.on(EVENTS.ScanSeries, resp => { this.messagesSource.next({ event: EVENTS.ScanSeries, @@ -60,15 +78,38 @@ export class MessageHubService { this.scanSeries.emit(resp.body); }); - this.hubConnection.on(EVENTS.ScanLibrary, resp => { + this.hubConnection.on(EVENTS.ScanLibraryProgress, resp => { this.messagesSource.next({ - event: EVENTS.ScanLibrary, + event: EVENTS.ScanLibraryProgress, payload: resp.body }); this.scanLibrary.emit(resp.body); - // if ((resp.body as ScanLibraryEvent).stage === 'complete') { - // this.toastr. - // } + }); + + this.hubConnection.on(EVENTS.SeriesAddedToCollection, resp => { + this.messagesSource.next({ + event: EVENTS.SeriesAddedToCollection, + payload: resp.body + }); + }); + + this.hubConnection.on(EVENTS.SeriesAdded, resp => { + this.messagesSource.next({ + event: EVENTS.SeriesAdded, + payload: resp.body + }); + this.seriesAdded.emit(resp.body); + if (this.isAdmin) { + this.toastr.info('Series ' + (resp.body as SeriesAddedEvent).seriesName + ' added'); + } + }); + + this.hubConnection.on(EVENTS.RefreshMetadata, resp => { + this.messagesSource.next({ + event: EVENTS.RefreshMetadata, + payload: resp.body + }); + this.refreshMetadata.emit(resp.body); }); this.hubConnection.on(EVENTS.UpdateAvailable, resp => { @@ -90,7 +131,9 @@ export class MessageHubService { } stopHubConnection() { - this.hubConnection.stop().catch(err => console.error(err)); + if (this.hubConnection) { + this.hubConnection.stop().catch(err => console.error(err)); + } } sendMessage(methodName: string, body?: any) { diff --git a/UI/Web/src/app/_services/presence-hub.service.ts b/UI/Web/src/app/_services/presence-hub.service.ts deleted file mode 100644 index f0fe970d1..000000000 --- a/UI/Web/src/app/_services/presence-hub.service.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Injectable } from '@angular/core'; -import { HubConnection, HubConnectionBuilder } from '@microsoft/signalr'; -import { User } from '@sentry/angular'; -import { ToastrService } from 'ngx-toastr'; -import { BehaviorSubject } from 'rxjs'; -import { environment } from 'src/environments/environment'; - -@Injectable({ - providedIn: 'root' -}) -export class PresenceHubService { - - hubUrl = environment.hubUrl; - private hubConnection!: HubConnection; - private onlineUsersSource = new BehaviorSubject([]); - onlineUsers$ = this.onlineUsersSource.asObservable(); - - constructor(private toatsr: ToastrService) { } - - createHubConnection(user: User) { - this.hubConnection = new HubConnectionBuilder() - .withUrl(this.hubUrl + 'presence', { - accessTokenFactory: () => user.token - }) - .withAutomaticReconnect() - .build(); - - this.hubConnection - .start() - .catch(err => console.error(err)); - - this.hubConnection.on('GetOnlineUsers', (usernames: string[]) => { - this.onlineUsersSource.next(usernames); - }); - } - - stopHubConnection() { - this.hubConnection.stop().catch(err => console.error(err)); - } -} diff --git a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html index c7f16ebef..323f4b35f 100644 --- a/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html +++ b/UI/Web/src/app/admin/_modals/reset-password-modal/reset-password-modal.component.html @@ -1,6 +1,6 @@
    -
  • +
  • - {{library.name | titlecase}} + {{library.name}}  +
    + Scan for {{library.name}} in progress +
    - - + +

    Type: {{libraryType(library.type)}}
    Shared Folders: {{library.folders.length + ' folders'}}
    +
    + Last Scanned: + Never + + {{library.lastScanned | date: 'short'}} + +
  • diff --git a/UI/Web/src/app/admin/manage-library/manage-library.component.ts b/UI/Web/src/app/admin/manage-library/manage-library.component.ts index f3353ed7f..904c6f584 100644 --- a/UI/Web/src/app/admin/manage-library/manage-library.component.ts +++ b/UI/Web/src/app/admin/manage-library/manage-library.component.ts @@ -4,8 +4,10 @@ import { ToastrService } from 'ngx-toastr'; import { Subject } from 'rxjs'; import { take, takeUntil } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; +import { ScanLibraryProgressEvent } from 'src/app/_models/events/scan-library-progress-event'; import { Library, LibraryType } from 'src/app/_models/library'; import { LibraryService } from 'src/app/_services/library.service'; +import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { LibraryEditorModalComponent } from '../_modals/library-editor-modal/library-editor-modal.component'; @Component({ @@ -22,13 +24,38 @@ export class ManageLibraryComponent implements OnInit, OnDestroy { * If a deletion is in progress for a library */ deletionInProgress: boolean = false; + scanInProgress: {[key: number]: {progress: boolean, timestamp?: string}} = {}; + libraryTrackBy = (index: number, item: Library) => `${item.name}_${item.lastScanned}_${item.type}_${item.folders.length}`; private readonly onDestroy = new Subject(); - constructor(private modalService: NgbModal, private libraryService: LibraryService, private toastr: ToastrService, private confirmService: ConfirmService) { } + constructor(private modalService: NgbModal, private libraryService: LibraryService, + private toastr: ToastrService, private confirmService: ConfirmService, + private hubService: MessageHubService) { } ngOnInit(): void { this.getLibraries(); + + // when a progress event comes in, show it on the UI next to library + this.hubService.messages$.pipe(takeUntil(this.onDestroy)).subscribe((event) => { + if (event.event != EVENTS.ScanLibraryProgress) return; + + const scanEvent = event.payload as ScanLibraryProgressEvent; + this.scanInProgress[scanEvent.libraryId] = {progress: scanEvent.progress !== 100}; + if (scanEvent.progress === 0) { + this.scanInProgress[scanEvent.libraryId].timestamp = scanEvent.eventTime; + } + + if (this.scanInProgress[scanEvent.libraryId].progress === false && scanEvent.progress === 100) { + this.libraryService.getLibraries().pipe(take(1)).subscribe(libraries => { + const newLibrary = libraries.find(lib => lib.id === scanEvent.libraryId); + const existingLibrary = this.libraries.find(lib => lib.id === scanEvent.libraryId); + if (existingLibrary !== undefined) { + existingLibrary.lastScanned = newLibrary?.lastScanned || existingLibrary.lastScanned; + } + }); + } + }); } ngOnDestroy() { diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html index 796e9fccf..728876284 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.html +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.html @@ -1,6 +1,6 @@
    -

    Port and Logging Level require a manual restart of Kavita to take effect.

    +

    Port, Base Url, and Logging Level require a manual restart of Kavita to take effect.

      Where the server place temporary files when reading. This will be cleaned up on a regular basis. @@ -8,6 +8,13 @@
    + +
      Port the server listens on. This is fixed if you are running on Docker. Requires restart to take effect. @@ -42,6 +49,15 @@
    +
    + +

    By disabling authentication, all non-admin users will be able to login by just their username. No password will be required to authenticate.

    +
    + + +
    +
    +

    Reoccuring Tasks

      diff --git a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts index d4d93a87c..c05464695 100644 --- a/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts +++ b/UI/Web/src/app/admin/manage-settings/manage-settings.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; +import { ConfirmService } from 'src/app/shared/confirm.service'; import { SettingsService } from '../settings.service'; import { ServerSettings } from '../_models/server-settings'; @@ -17,7 +18,7 @@ export class ManageSettingsComponent implements OnInit { taskFrequencies: Array = []; logLevels: Array = []; - constructor(private settingsService: SettingsService, private toastr: ToastrService) { } + constructor(private settingsService: SettingsService, private toastr: ToastrService, private confirmService: ConfirmService) { } ngOnInit(): void { this.settingsService.getTaskFrequencies().pipe(take(1)).subscribe(frequencies => { @@ -35,6 +36,8 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.addControl('loggingLevel', new FormControl(this.serverSettings.loggingLevel, [Validators.required])); this.settingsForm.addControl('allowStatCollection', new FormControl(this.serverSettings.allowStatCollection, [Validators.required])); this.settingsForm.addControl('enableOpds', new FormControl(this.serverSettings.enableOpds, [Validators.required])); + this.settingsForm.addControl('enableAuthentication', new FormControl(this.serverSettings.enableAuthentication, [Validators.required])); + this.settingsForm.addControl('baseUrl', new FormControl(this.serverSettings.baseUrl, [Validators.required])); }); } @@ -46,15 +49,29 @@ export class ManageSettingsComponent implements OnInit { this.settingsForm.get('loggingLevel')?.setValue(this.serverSettings.loggingLevel); this.settingsForm.get('allowStatCollection')?.setValue(this.serverSettings.allowStatCollection); this.settingsForm.get('enableOpds')?.setValue(this.serverSettings.enableOpds); + this.settingsForm.get('enableAuthentication')?.setValue(this.serverSettings.enableAuthentication); + this.settingsForm.get('baseUrl')?.setValue(this.serverSettings.baseUrl); } - saveSettings() { + async saveSettings() { const modelSettings = this.settingsForm.value; - this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => { + if (this.settingsForm.get('enableAuthentication')?.value === false) { + if (!await this.confirmService.confirm('Disabling Authentication opens your server up to unauthorized access and possible hacking. Are you sure you want to continue with this?')) { + return; + } + } + + const informUserAfterAuthenticationEnabled = this.settingsForm.get('enableAuthentication')?.value && !this.serverSettings.enableAuthentication; + + this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe(async (settings: ServerSettings) => { this.serverSettings = settings; this.resetForm(); this.toastr.success('Server settings updated'); + + if (informUserAfterAuthenticationEnabled) { + await this.confirmService.alert('You have just re-enabled authentication. All non-admin users have been re-assigned a password of "[k.2@RZ!mxCQkJzE". This is a publicly known password. Please change their users passwords or request them to.'); + } }, (err: any) => { console.error('error: ', err); }); diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.html b/UI/Web/src/app/admin/manage-users/manage-users.component.html index 5aff35f12..5b6ce57b9 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.html +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.html @@ -9,7 +9,7 @@
  • - {{member.username | titlecase}} (You) + {{member.username | titlecase}} (You)
    @@ -19,7 +19,7 @@
    Last Active: Never - {{member.lastActive | date: 'MM/dd/yyyy'}} + {{member.lastActive | date: 'short'}}
    Sharing: {{formatLibraries(member)}}
    diff --git a/UI/Web/src/app/admin/manage-users/manage-users.component.ts b/UI/Web/src/app/admin/manage-users/manage-users.component.ts index fd3d50f62..c1982f130 100644 --- a/UI/Web/src/app/admin/manage-users/manage-users.component.ts +++ b/UI/Web/src/app/admin/manage-users/manage-users.component.ts @@ -1,6 +1,6 @@ import { Component, OnDestroy, OnInit } from '@angular/core'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; -import { take, takeUntil } from 'rxjs/operators'; +import { take } from 'rxjs/operators'; import { MemberService } from 'src/app/_services/member.service'; import { Member } from 'src/app/_models/member'; import { User } from 'src/app/_models/user'; @@ -10,8 +10,8 @@ import { ToastrService } from 'ngx-toastr'; import { ResetPasswordModalComponent } from '../_modals/reset-password-modal/reset-password-modal.component'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { EditRbsModalComponent } from '../_modals/edit-rbs-modal/edit-rbs-modal.component'; -import { PresenceHubService } from 'src/app/_services/presence-hub.service'; import { Subject } from 'rxjs'; +import { MessageHubService } from 'src/app/_services/message-hub.service'; @Component({ selector: 'app-manage-users', @@ -34,7 +34,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy { private modalService: NgbModal, private toastr: ToastrService, private confirmService: ConfirmService, - public presence: PresenceHubService) { + public messageHub: MessageHubService) { this.accountService.currentUser$.pipe(take(1)).subscribe((user: User) => { this.loggedInUsername = user.username; }); @@ -77,7 +77,7 @@ export class ManageUsersComponent implements OnInit, OnDestroy { this.createMemberToggle = true; } - onMemberCreated(success: boolean) { + onMemberCreated(createdUser: User | null) { this.createMemberToggle = false; this.loadMembers(); } diff --git a/UI/Web/src/app/admin/settings.service.ts b/UI/Web/src/app/admin/settings.service.ts index 1c7f717c7..f3ab0c3f7 100644 --- a/UI/Web/src/app/admin/settings.service.ts +++ b/UI/Web/src/app/admin/settings.service.ts @@ -35,4 +35,8 @@ export class SettingsService { getOpdsEnabled() { return this.http.get(this.baseUrl + 'settings/opds-enabled', {responseType: 'text' as 'json'}); } + + getAuthenticationEnabled() { + return this.http.get(this.baseUrl + 'settings/authentication-enabled', {responseType: 'text' as 'json'}); + } } diff --git a/UI/Web/src/app/app-routing.module.ts b/UI/Web/src/app/app-routing.module.ts index e65fd5ee3..5ddee3f2b 100644 --- a/UI/Web/src/app/app-routing.module.ts +++ b/UI/Web/src/app/app-routing.module.ts @@ -1,8 +1,6 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; -import { HomeComponent } from './home/home.component'; import { LibraryDetailComponent } from './library-detail/library-detail.component'; -import { LibraryComponent } from './library/library.component'; import { NotConnectedComponent } from './not-connected/not-connected.component'; import { SeriesDetailComponent } from './series-detail/series-detail.component'; import { RecentlyAddedComponent } from './recently-added/recently-added.component'; @@ -10,13 +8,12 @@ import { UserLoginComponent } from './user-login/user-login.component'; import { AuthGuard } from './_guards/auth.guard'; import { LibraryAccessGuard } from './_guards/library-access.guard'; import { InProgressComponent } from './in-progress/in-progress.component'; -import { DashboardComponent as AdminDashboardComponent } from './admin/dashboard/dashboard.component'; import { DashboardComponent } from './dashboard/dashboard.component'; // TODO: Once we modularize the components, use this and measure performance impact: https://angular.io/guide/lazy-loading-ngmodules#preloading-modules const routes: Routes = [ - {path: '', component: HomeComponent}, + {path: '', component: UserLoginComponent}, { path: 'admin', loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule) @@ -62,7 +59,7 @@ const routes: Routes = [ }, {path: 'login', component: UserLoginComponent}, {path: 'no-connection', component: NotConnectedComponent}, - {path: '**', component: HomeComponent, pathMatch: 'full'} + {path: '**', component: UserLoginComponent, pathMatch: 'full'} ]; @NgModule({ diff --git a/UI/Web/src/app/app.component.ts b/UI/Web/src/app/app.component.ts index afb8d94b6..949bf0534 100644 --- a/UI/Web/src/app/app.component.ts +++ b/UI/Web/src/app/app.component.ts @@ -5,7 +5,6 @@ import { AccountService } from './_services/account.service'; import { LibraryService } from './_services/library.service'; import { MessageHubService } from './_services/message-hub.service'; import { NavService } from './_services/nav.service'; -import { PresenceHubService } from './_services/presence-hub.service'; import { StatsService } from './_services/stats.service'; import { filter } from 'rxjs/operators'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; @@ -19,7 +18,7 @@ export class AppComponent implements OnInit { constructor(private accountService: AccountService, public navService: NavService, private statsService: StatsService, private messageHub: MessageHubService, - private presenceHub: PresenceHubService, private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal) { + private libraryService: LibraryService, private router: Router, private ngbModal: NgbModal) { // Close any open modals when a route change occurs router.events @@ -47,8 +46,7 @@ export class AppComponent implements OnInit { if (user) { this.navService.setDarkMode(user.preferences.siteDarkMode); - this.messageHub.createHubConnection(user); - this.presenceHub.createHubConnection(user); + this.messageHub.createHubConnection(user, this.accountService.hasAdminRole(user)); this.libraryService.getLibraryNames().pipe(take(1)).subscribe(() => {/* No Operation */}); } else { this.navService.setDarkMode(true); diff --git a/UI/Web/src/app/app.module.ts b/UI/Web/src/app/app.module.ts index 9a029e665..ed8074d08 100644 --- a/UI/Web/src/app/app.module.ts +++ b/UI/Web/src/app/app.module.ts @@ -1,13 +1,13 @@ import { BrowserModule, Title } from '@angular/platform-browser'; import { APP_INITIALIZER, ErrorHandler, NgModule } from '@angular/core'; +import { APP_BASE_HREF } from '@angular/common'; import { AppRoutingModule } from './app-routing.module'; import { AppComponent } from './app.component'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; -import { HomeComponent } from './home/home.component'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; -import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; -import { NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; +import { HttpClient, HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http'; +import { NgbCollapseModule, NgbDropdownModule, NgbNavModule, NgbPaginationModule, NgbRatingModule } from '@ng-bootstrap/ng-bootstrap'; import { NavHeaderComponent } from './nav-header/nav-header.component'; import { JwtInterceptor } from './_interceptors/jwt.interceptor'; import { UserLoginComponent } from './user-login/user-login.component'; @@ -22,72 +22,21 @@ import { AutocompleteLibModule } from 'angular-ng-autocomplete'; import { ReviewSeriesModalComponent } from './_modals/review-series-modal/review-series-modal.component'; import { CarouselModule } from './carousel/carousel.module'; - -import * as Sentry from '@sentry/angular'; -import { environment } from 'src/environments/environment'; -import { version } from 'package.json'; -import { Router } from '@angular/router'; -import { RewriteFrames as RewriteFramesIntegration } from '@sentry/integrations'; -import { Dedupe as DedupeIntegration } from '@sentry/integrations'; import { PersonBadgeComponent } from './person-badge/person-badge.component'; import { TypeaheadModule } from './typeahead/typeahead.module'; import { RecentlyAddedComponent } from './recently-added/recently-added.component'; +import { InProgressComponent } from './in-progress/in-progress.component'; +import { DashboardComponent } from './dashboard/dashboard.component'; import { CardsModule } from './cards/cards.module'; import { CollectionsModule } from './collections/collections.module'; -import { InProgressComponent } from './in-progress/in-progress.component'; -import { SAVER, getSaver } from './shared/_providers/saver.provider'; import { ReadingListModule } from './reading-list/reading-list.module'; -import { DashboardComponent } from './dashboard/dashboard.component'; +import { SAVER, getSaver } from './shared/_providers/saver.provider'; +import { ConfigData } from './_models/config-data'; -let sentryProviders: any[] = []; - -if (environment.production) { - Sentry.init({ - dsn: 'https://db1a1f6445994b13a6f479512aecdd48@o641015.ingest.sentry.io/5757426', - environment: environment.production ? 'prod' : 'dev', - release: version, - integrations: [ - new Sentry.Integrations.GlobalHandlers({ - onunhandledrejection: true, - onerror: true - }), - new DedupeIntegration(), - new RewriteFramesIntegration(), - ], - ignoreErrors: [new RegExp(/\/api\/admin/)], - tracesSampleRate: 0, - }); - - Sentry.configureScope(scope => { - scope.setUser({ - username: 'Not authorized' - }); - scope.setTag('production', environment.production); - scope.setTag('version', version); - }); - - sentryProviders = [{ - provide: ErrorHandler, - useValue: Sentry.createErrorHandler({ - showDialog: false, - }), - }, - { - provide: Sentry.TraceService, - deps: [Router], - }, - { - provide: APP_INITIALIZER, - useFactory: () => () => {}, - deps: [Sentry.TraceService], - multi: true, - }]; -} @NgModule({ declarations: [ AppComponent, - HomeComponent, NavHeaderComponent, UserLoginComponent, LibraryComponent, @@ -114,6 +63,8 @@ if (environment.production) { NgbNavModule, NgbPaginationModule, + NgbCollapseModule, // Login + SharedModule, CarouselModule, TypeaheadModule, @@ -134,7 +85,7 @@ if (environment.production) { {provide: HTTP_INTERCEPTORS, useClass: JwtInterceptor, multi: true}, Title, {provide: SAVER, useFactory: getSaver}, - ...sentryProviders, + { provide: APP_BASE_HREF, useFactory: (config: ConfigData) => config.baseUrl, deps: [ConfigData] }, ], entryComponents: [], bootstrap: [AppComponent] diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html index 93f458d35..99bba7fc2 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.html +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.html @@ -58,17 +58,16 @@
    - - +

    -
    {{pageNum}}
    -
    +
    {{pageNum}}
    +
    -
    {{maxPages - 1}}
    +
    {{maxPages - 1}}
    @@ -100,24 +99,15 @@
    - -
    -
    +
    -
    +
    @@ -138,7 +128,7 @@ [disabled]="IsNextDisabled" (click)="nextPage()" title="{{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}} Page"> {{readingDirection === ReadingDirection.LeftToRight ? 'Next' : 'Previous'}}  - +
    diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss index 042274399..f7c62217e 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.scss @@ -28,6 +28,7 @@ src: url(../../../assets/fonts/RocknRoll_One/RocknRollOne-Regular.ttf) format("truetype"); } +$dark-form-background-no-opacity: rgb(1, 4, 9); $primary-color: #0062cc; .control-container { @@ -42,6 +43,15 @@ $primary-color: #0062cc; } } +.page-stub { + margin-top: 6px; + padding-left: 2px; + padding-right: 2px; +} + +.fixed-top { + z-index: 1022; +} .dark-mode { @@ -73,60 +83,12 @@ $primary-color: #0062cc; color: #8db2e5 !important; } - // Coppied - // html, body { - // color: #dcdcdc !important; - // background-image: none !important; - // background-color: #292929 !important; - // } - - // html::before, body::before { - // background-image: none !important; - // } - - // html *:not(input) {color: #dcdcdc !important} - // html * {background-color: rgb(41, 41, 41, 0.90) !important} - - // html *, html *[id], html *[class] { - // box-shadow: none !important; - // text-shadow: none !important; - // border-radius: unset !important; - // border-color: #555555 !important; - // outline-color: #555555 !important; - // } - - img, img[src] { + img, img[src] { z-index: 1; filter: brightness(0.85) !important; background-color: initial !important; - } + } - // video, video[src] { - // z-index: 1; - // background-color: transparent !important; - // } - - // input:not([type='button']):not([type='submit']) { - // color: #dcdcdc !important; - // background-image: none !important; - // background-color: #333333 !important; - // } - - // textarea, textarea[class], input[type='text'], input[type='text'][class] { - // color: #dcdcdc !important; - // background-color: #555555 !important; - // } - - // svg:not([fill]) {fill: #7d7d7d !important} - // li, select {background-image: none !important} - // input[type='text'], input[type='search'] {text-indent: 10px} - // a {background-color: rgba(255, 255, 255, 0.01) !important} - // html cite, html cite *, html cite *[class] {color: #029833 !important} - // svg[fill], button, input[type='button'], input[type='submit'] {opacity: 0.85 !important} - - // :before {color: #dcdcdc !important} - // :link:not(cite), :link *:not(cite) {color: #8db2e5 !important} - // :visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important} :visited, :visited *, :visited *[class] {color: rgb(211, 138, 138) !important} :link:not(cite), :link *:not(cite) {color: #8db2e5 !important} } @@ -134,6 +96,48 @@ $primary-color: #0062cc; .reading-bar { background-color: white; overflow: hidden; + box-shadow: 0 0 6px 0 rgb(0 0 0 / 70%); +} + +.dark-mode { + .reading-bar, .book-title, .drawer-body, .drawer-container { + background-color: $dark-form-background-no-opacity; + } + button { + background-color: $dark-form-background-no-opacity; + } + + .btn { + &.btn-secondary { + border-color: transparent; + + &:hover, &:focus { + border-color: #545b62; + } + } + + &.btn-outline-secondary { + border-color: transparent; + + &:hover, &:focus { + border-color: #545b62; + } + } + + span { + background-color: unset; + } + + i { + background-color: unset; + } + } +} + +::ng-deep .dark-mode .drawer-container { + .header, body, *:not(.progress-bar) { + background-color: $dark-form-background-no-opacity !important; + } } @media(max-width: 875px) { @@ -200,4 +204,41 @@ $primary-color: #0062cc; .highlight-2 { background-color: rgba(65, 105, 225, 0.5) !important; animation: fadein .5s both; -} \ No newline at end of file +} + +.btn { + &.btn-secondary { + color: #6c757d; + border-color: transparent; + background-color: unset; + + &:hover, &:focus { + border-color: #545b62; + } + } + + &.btn-outline-secondary { + border-color: transparent; + background-color: unset; + + &:hover, &:focus { + border-color: #545b62; + } + } + + span { + background-color: unset; + color: #6c757d; + } + + i { + background-color: unset; + color: #6c757d; + } + + &:active { + * { + color: white; + } + } +} diff --git a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts index 8f3984565..628c0a35d 100644 --- a/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/book-reader/book-reader.component.ts @@ -14,15 +14,16 @@ import { SeriesService } from 'src/app/_services/series.service'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; import { BookService } from '../book.service'; -import { KEY_CODES } from 'src/app/shared/_services/utility.service'; +import { KEY_CODES, UtilityService } from 'src/app/shared/_services/utility.service'; import { BookChapterItem } from '../_models/book-chapter-item'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { Stack } from 'src/app/shared/data-structures/stack'; -import { Preferences } from 'src/app/_models/preferences/preferences'; import { MemberService } from 'src/app/_services/member.service'; import { ReadingDirection } from 'src/app/_models/preferences/reading-direction'; import { ScrollService } from 'src/app/scroll.service'; import { MangaFormat } from 'src/app/_models/manga-format'; +import { LibraryService } from 'src/app/_services/library.service'; +import { LibraryType } from 'src/app/_models/library'; interface PageStyle { @@ -166,11 +167,14 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { pageAnchors: {[n: string]: number } = {}; currentPageAnchor: string = ''; - intersectionObserver: IntersectionObserver = new IntersectionObserver((entries) => this.handleIntersection(entries), { threshold: [1] }); /** * Last seen progress part path */ lastSeenScrollPartPath: string = ''; + /** + * Library Type used for rendering chapter or issue + */ + libraryType: LibraryType = LibraryType.Book; /** * Hack: Override background color for reader and restore it onDestroy @@ -186,10 +190,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { color: #e83e8c !important; } - // .btn-icon { - // background-color: transparent; - // } - :link, a { color: #8db2e5 !important; } @@ -205,25 +205,31 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { return ReadingDirection; } - get IsPrevDisabled() { + get IsPrevDisabled(): boolean { if (this.readingDirection === ReadingDirection.LeftToRight) { + // Acting as Previous button return this.prevPageDisabled && this.pageNum === 0; - } - return this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1; + } else { + // Acting as a Next button + return this.nextPageDisabled && this.pageNum + 1 > this.maxPages - 1; + } } - get IsNextDisabled() { + get IsNextDisabled(): boolean { if (this.readingDirection === ReadingDirection.LeftToRight) { - this.nextPageDisabled && this.pageNum + 1 >= this.maxPages - 1; + // Acting as Next button + return this.nextPageDisabled && this.pageNum + 1 > this.maxPages - 1; + } else { + // Acting as Previous button + return this.prevPageDisabled && this.pageNum === 0; } - return this.prevPageDisabled && this.pageNum === 0; } constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, private seriesService: SeriesService, private readerService: ReaderService, private location: Location, private renderer: Renderer2, private navService: NavService, private toastr: ToastrService, private domSanitizer: DomSanitizer, private bookService: BookService, private memberService: MemberService, - private scrollService: ScrollService) { + private scrollService: ScrollService, private utilityService: UtilityService, private libraryService: LibraryService) { this.navService.hideNavBar(); this.darkModeStyleElem = this.renderer.createElement('style'); @@ -279,6 +285,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { fromEvent(window, 'scroll') .pipe(debounceTime(200), takeUntil(this.onDestroy)).subscribe((event) => { if (this.isLoading) return; + + // Highlight the current chapter we are on if (Object.keys(this.pageAnchors).length !== 0) { // get the height of the document so we can capture markers that are halfway on the document viewport const verticalOffset = this.scrollService.scrollPosition + (document.body.offsetHeight / 2); @@ -286,16 +294,41 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const alreadyReached = Object.values(this.pageAnchors).filter((i: number) => i <= verticalOffset); if (alreadyReached.length > 0) { this.currentPageAnchor = Object.keys(this.pageAnchors)[alreadyReached.length - 1]; - - if (!this.incognitoMode) { - this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); - } - return; } else { this.currentPageAnchor = ''; } } + + // Find the element that is on screen to bookmark against + const intersectingEntries = Array.from(this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span')) + .filter(element => !element.classList.contains('no-observe')) + .filter(entry => { + return this.utilityService.isInViewport(entry, this.topOffset); + }); + + intersectingEntries.sort((a: Element, b: Element) => { + const aTop = a.getBoundingClientRect().top; + const bTop = b.getBoundingClientRect().top; + if (aTop < bTop) { + return -1; + } + if (aTop > bTop) { + return 1; + } + + return 0; + }); + + if (intersectingEntries.length > 0) { + let path = this.getXPathTo(intersectingEntries[0]); + if (path === '') { return; } + if (!path.startsWith('id')) { + path = '//html[1]/' + path; + } + this.lastSeenScrollPartPath = path; + } + if (this.lastSeenScrollPartPath !== '' && !this.incognitoMode) { this.readerService.saveProgress(this.seriesId, this.volumeId, this.chapterId, this.pageNum, this.lastSeenScrollPartPath).pipe(take(1)).subscribe(() => {/* No operation */}); } @@ -326,7 +359,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.onDestroy.next(); this.onDestroy.complete(); - this.intersectionObserver.disconnect(); } ngOnInit(): void { @@ -392,6 +424,11 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.continuousChaptersStack.push(this.chapterId); + + this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => { + this.libraryType = type; + }); + if (this.pageNum >= this.maxPages) { @@ -443,12 +480,8 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - handleIntersection(entries: IntersectionObserverEntry[]) { - let intersectingEntries = Array.from(entries) - .filter(entry => entry.isIntersecting) - .map(entry => entry.target) - intersectingEntries.sort((a: Element, b: Element) => { - const aTop = a.getBoundingClientRect().top; + sortElements(a: Element, b: Element) { + const aTop = a.getBoundingClientRect().top; const bTop = b.getBoundingClientRect().top; if (aTop < bTop) { return -1; @@ -458,17 +491,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } return 0; - }); - - - if (intersectingEntries.length > 0) { - let path = this.getXPathTo(intersectingEntries[0]); - if (path === '') { return; } - if (!path.startsWith('id')) { - path = '//html[1]/' + path; - } - this.lastSeenScrollPartPath = path; - } } loadNextChapter() { @@ -515,10 +537,10 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); this.init(); - this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000}); + this.toastr.info(direction + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase() + ' loaded', '', {timeOut: 3000}); } else { // This will only happen if no actual chapter can be found - this.toastr.warning('Could not find ' + direction + ' chapter'); + this.toastr.warning('Could not find ' + direction.toLowerCase() + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase()); this.isLoading = false; if (direction === 'Prev') { this.prevPageDisabled = true; @@ -555,9 +577,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { margin = this.user.preferences.bookReaderMargin + '%'; } this.pageStyles = {'font-family': this.user.preferences.bookReaderFontFamily, 'font-size': this.user.preferences.bookReaderFontSize + '%', 'margin-left': margin, 'margin-right': margin, 'line-height': this.user.preferences.bookReaderLineSpacing + '%'}; - if (this.user.preferences.siteDarkMode && !this.user.preferences.bookReaderDarkMode) { - this.user.preferences.bookReaderDarkMode = true; - } + this.toggleDarkMode(this.user.preferences.bookReaderDarkMode); } else { this.pageStyles = {'font-family': 'default', 'font-size': '100%', 'margin-left': margin, 'margin-right': margin, 'line-height': '100%'}; @@ -651,12 +671,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } setupPageAnchors() { - this.readingSectionElemRef.nativeElement.querySelectorAll('div,o,p,ul,li,a,img,h1,h2,h3,h4,h5,h6,span').forEach(elem => { - if (!elem.classList.contains('no-observe')) { - this.intersectionObserver.observe(elem); - } - }); - this.pageAnchors = {}; this.currentPageAnchor = ''; const ids = this.chapters.map(item => item.children).flat().filter(item => item.page === this.pageNum).map(item => item.part).filter(item => item.length > 0); @@ -869,7 +883,7 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } getDarkModeBackgroundColor() { - return this.darkMode ? '#292929' : '#fff'; + return this.darkMode ? '#010409' : '#fff'; } setOverrideStyles() { @@ -890,33 +904,6 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - saveSettings() { - if (this.user === undefined) return; - const modelSettings = this.settingsForm.value; - const data: Preferences = { - readingDirection: this.user.preferences.readingDirection, - scalingOption: this.user.preferences.scalingOption, - pageSplitOption: this.user.preferences.pageSplitOption, - autoCloseMenu: this.user.preferences.autoCloseMenu, - readerMode: this.user.preferences.readerMode, - bookReaderDarkMode: this.darkMode, - bookReaderFontFamily: modelSettings.bookReaderFontFamily, - bookReaderFontSize: parseInt(this.pageStyles['font-size'].substr(0, this.pageStyles['font-size'].length - 1), 10), - bookReaderLineSpacing: parseInt(this.pageStyles['line-height'].replace('!important', '').trim(), 10), - bookReaderMargin: parseInt(this.pageStyles['margin-left'].replace('%', '').replace('!important', '').trim(), 10), - bookReaderTapToPaginate: this.clickToPaginate, - bookReaderReadingDirection: this.readingDirection, - siteDarkMode: this.user.preferences.siteDarkMode, - }; - this.accountService.updatePreferences(data).pipe(take(1)).subscribe((updatedPrefs) => { - this.toastr.success('User settings updated'); - if (this.user) { - this.user.preferences = updatedPrefs; - } - this.resetSettings(); - }); - } - toggleDrawer() { this.topOffset = this.stickyTopElemRef.nativeElement?.offsetHeight; this.drawerOpen = !this.drawerOpen; diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html index d18987f6b..80726b878 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.html @@ -1,7 +1,10 @@
    - Added: {{(data.created | date: 'MM/dd/yyyy') || '-'}} + Added: {{(data.created | date: 'short') || '-'}}
    Pages: {{data.pages}} @@ -27,18 +30,19 @@
    -

    Chapters

    +

    {{utilityService.formatChapterName(libraryType) + 's'}}

    • - +
      - -   - Chapter {{formatChapterNumber(chapter)}} + +   + {{utilityService.formatChapterName(libraryType, true, false) }} {{formatChapterNumber(chapter)}} + {{chapter.pagesRead}} / {{chapter.pages}} UNREAD diff --git a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts index 6cd02f5c8..3f15edd65 100644 --- a/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts +++ b/UI/Web/src/app/cards/_modals/card-details-modal/card-details-modal.component.ts @@ -13,6 +13,8 @@ import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; import { UploadService } from 'src/app/_services/upload.service'; import { ChangeCoverImageModalComponent } from '../change-cover-image/change-cover-image-modal.component'; +import { LibraryType } from '../../../_models/library'; +import { LibraryService } from '../../../_services/library.service'; @@ -39,12 +41,16 @@ export class CardDetailsModalComponent implements OnInit { isAdmin: boolean = false; actions: ActionItem[] = []; chapterActions: ActionItem[] = []; + libraryType: LibraryType = LibraryType.Manga; + get LibraryType(): typeof LibraryType { + return LibraryType; + } constructor(private modalService: NgbModal, public modal: NgbActiveModal, public utilityService: UtilityService, public imageService: ImageService, private uploadService: UploadService, private toastr: ToastrService, private accountService: AccountService, private actionFactoryService: ActionFactoryService, - private actionService: ActionService, private router: Router) { } + private actionService: ActionService, private router: Router, private libraryService: LibraryService) { } ngOnInit(): void { this.isChapter = this.utilityService.isChapter(this.data); @@ -55,6 +61,10 @@ export class CardDetailsModalComponent implements OnInit { } }); + this.libraryService.getLibraryType(this.libraryId).subscribe(type => { + this.libraryType = type; + }); + this.chapterActions = this.actionFactoryService.getChapterActions(this.handleChapterActionCallback.bind(this)).filter(item => item.action !== Action.Edit); if (this.isChapter) { @@ -94,7 +104,7 @@ export class CardDetailsModalComponent implements OnInit { const chapter = this.utilityService.asChapter(this.data) chapter.coverImage = this.imageService.getChapterCoverImage(chapter.id); modalRef.componentInstance.chapter = chapter; - modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : 'Chapter ') + chapter.range + '\'s Cover'; + modalRef.componentInstance.title = 'Select ' + (chapter.isSpecial ? '' : this.utilityService.formatChapterName(this.libraryType, false, true)) + chapter.range + '\'s Cover'; } else { const volume = this.utilityService.asVolume(this.data); const chapters = volume.chapters; diff --git a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html index c354ac22d..f39ee04d1 100644 --- a/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html +++ b/UI/Web/src/app/cards/_modals/edit-series-modal/edit-series-modal.component.html @@ -95,7 +95,7 @@

      Information

      -
      Library: {{libraryName | titlecase}}
      +
      Library: {{libraryName | sentenceCase}}
      Format: {{utilityService.mangaFormat(series.format)}}

      Volumes

      @@ -110,10 +110,10 @@
      - Created: {{volume.created | date: 'MM/dd/yyyy'}} + Created: {{volume.created | date: 'short'}}
      - Last Modified: {{volume.lastModified | date: 'MM/dd/yyyy'}} + Last Modified: {{volume.lastModified | date: 'short'}}
      diff --git a/UI/Web/src/app/cards/card-item/card-item.component.html b/UI/Web/src/app/cards/card-item/card-item.component.html index c4fac279a..7fee96b23 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.html +++ b/UI/Web/src/app/cards/card-item/card-item.component.html @@ -38,6 +38,6 @@
      - {{libraryName | titlecase}} + {{libraryName | sentenceCase}}
      \ No newline at end of file diff --git a/UI/Web/src/app/cards/card-item/card-item.component.ts b/UI/Web/src/app/cards/card-item/card-item.component.ts index 740cf6043..b0974ba5a 100644 --- a/UI/Web/src/app/cards/card-item/card-item.component.ts +++ b/UI/Web/src/app/cards/card-item/card-item.component.ts @@ -120,14 +120,33 @@ export class CardItemComponent implements OnInit, OnDestroy { prevTouchTime: number = 0; + prevOffset: number = 0; @HostListener('touchstart', ['$event']) onTouchStart(event: TouchEvent) { + if (!this.allowSelection) return; + const verticalOffset = (window.pageYOffset + || document.documentElement.scrollTop + || document.body.scrollTop || 0); + this.prevTouchTime = event.timeStamp; + this.prevOffset = verticalOffset; } @HostListener('touchend', ['$event']) onTouchEnd(event: TouchEvent) { - if (event.timeStamp - this.prevTouchTime >= 200) { + if (!this.allowSelection) return; + const delta = event.timeStamp - this.prevTouchTime; + const verticalOffset = (window.pageYOffset + || document.documentElement.scrollTop + || document.body.scrollTop || 0); + + if (verticalOffset != this.prevOffset) { + this.prevTouchTime = 0; + + return; + } + + if (delta >= 300 && delta <= 1000) { this.handleSelection(); event.stopPropagation(); event.preventDefault(); diff --git a/UI/Web/src/app/cards/series-card/series-card.component.ts b/UI/Web/src/app/cards/series-card/series-card.component.ts index 57c2af992..11d869401 100644 --- a/UI/Web/src/app/cards/series-card/series-card.component.ts +++ b/UI/Web/src/app/cards/series-card/series-card.component.ts @@ -1,8 +1,8 @@ -import { Component, EventEmitter, Input, OnChanges, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnChanges, OnDestroy, OnInit, Output } from '@angular/core'; import { Router } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; +import { take, takeUntil, takeWhile } from 'rxjs/operators'; import { Series } from 'src/app/_models/series'; import { AccountService } from 'src/app/_services/account.service'; import { ImageService } from 'src/app/_services/image.service'; @@ -11,13 +11,16 @@ import { SeriesService } from 'src/app/_services/series.service'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { ActionService } from 'src/app/_services/action.service'; import { EditSeriesModalComponent } from '../_modals/edit-series-modal/edit-series-modal.component'; +import { RefreshMetadataEvent } from 'src/app/_models/events/refresh-metadata-event'; +import { MessageHubService } from 'src/app/_services/message-hub.service'; +import { Subject } from 'rxjs'; @Component({ selector: 'app-series-card', templateUrl: './series-card.component.html', styleUrls: ['./series-card.component.scss'] }) -export class SeriesCardComponent implements OnInit, OnChanges { +export class SeriesCardComponent implements OnInit, OnChanges, OnDestroy { @Input() data!: Series; @Input() libraryId = 0; @Input() suppressLibraryLink = false; @@ -41,12 +44,13 @@ export class SeriesCardComponent implements OnInit, OnChanges { isAdmin = false; actions: ActionItem[] = []; imageUrl: string = ''; + onDestroy: Subject = new Subject(); constructor(private accountService: AccountService, private router: Router, private seriesService: SeriesService, private toastr: ToastrService, private modalService: NgbModal, private confirmService: ConfirmService, public imageService: ImageService, private actionFactoryService: ActionFactoryService, - private actionService: ActionService) { + private actionService: ActionService, private hubService: MessageHubService) { this.accountService.currentUser$.pipe(take(1)).subscribe(user => { if (user) { this.isAdmin = this.accountService.hasAdminRole(user); @@ -58,6 +62,12 @@ export class SeriesCardComponent implements OnInit, OnChanges { ngOnInit(): void { if (this.data) { this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id)); + + this.hubService.refreshMetadata.pipe(takeWhile(event => event.libraryId === this.libraryId), takeUntil(this.onDestroy)).subscribe((event: RefreshMetadataEvent) => { + if (this.data.id === event.seriesId) { + this.imageUrl = this.imageService.randomize(this.imageService.getSeriesCoverImage(this.data.id)); + } + }); } } @@ -68,6 +78,11 @@ export class SeriesCardComponent implements OnInit, OnChanges { } } + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); + } + handleSeriesActionCallback(action: Action, series: Series) { switch (action) { case(Action.MarkAsRead): diff --git a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts index 5f3fa3145..086df24a3 100644 --- a/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts +++ b/UI/Web/src/app/collections/collection-detail/collection-detail.component.ts @@ -1,14 +1,16 @@ -import { Component, HostListener, OnInit } from '@angular/core'; +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { Router, ActivatedRoute } from '@angular/router'; import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { ToastrService } from 'ngx-toastr'; -import { take } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from 'src/app/cards/bulk-selection.service'; import { UpdateFilterEvent } from 'src/app/cards/card-detail-layout/card-detail-layout.component'; import { EditCollectionTagsComponent } from 'src/app/cards/_modals/edit-collection-tags/edit-collection-tags.component'; import { KEY_CODES } from 'src/app/shared/_services/utility.service'; import { CollectionTag } from 'src/app/_models/collection-tag'; +import { SeriesAddedToCollectionEvent } from 'src/app/_models/events/series-added-to-collection-event'; import { Pagination } from 'src/app/_models/pagination'; import { Series } from 'src/app/_models/series'; import { FilterItem, mangaFormatFilters, SeriesFilter } from 'src/app/_models/series-filter'; @@ -17,6 +19,7 @@ import { Action, ActionFactoryService, ActionItem } from 'src/app/_services/acti import { ActionService } from 'src/app/_services/action.service'; import { CollectionTagService } from 'src/app/_services/collection-tag.service'; import { ImageService } from 'src/app/_services/image.service'; +import { EVENTS, MessageHubService } from 'src/app/_services/message-hub.service'; import { SeriesService } from 'src/app/_services/series.service'; @Component({ @@ -24,7 +27,7 @@ import { SeriesService } from 'src/app/_services/series.service'; templateUrl: './collection-detail.component.html', styleUrls: ['./collection-detail.component.scss'] }) -export class CollectionDetailComponent implements OnInit { +export class CollectionDetailComponent implements OnInit, OnDestroy { collectionTag!: CollectionTag; tagImage: string = ''; @@ -40,6 +43,8 @@ export class CollectionDetailComponent implements OnInit { mangaFormat: null }; + private onDestory: Subject = new Subject(); + bulkActionCallback = (action: Action, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); const selectedSeries = this.series.filter((series, index: number) => selectedSeriesIndexies.includes(index + '')); @@ -68,7 +73,7 @@ export class CollectionDetailComponent implements OnInit { constructor(public imageService: ImageService, private collectionService: CollectionTagService, private router: Router, private route: ActivatedRoute, private seriesService: SeriesService, private toastr: ToastrService, private actionFactoryService: ActionFactoryService, private modalService: NgbModal, private titleService: Title, private accountService: AccountService, - public bulkSelectionService: BulkSelectionService, private actionService: ActionService) { + public bulkSelectionService: BulkSelectionService, private actionService: ActionService, private messageHub: MessageHubService) { this.router.routeReuseStrategy.shouldReuseRoute = () => false; this.accountService.currentUser$.pipe(take(1)).subscribe(user => { @@ -88,6 +93,18 @@ export class CollectionDetailComponent implements OnInit { ngOnInit(): void { this.collectionTagActions = this.actionFactoryService.getCollectionTagActions(this.handleCollectionActionCallback.bind(this)); + + this.messageHub.messages$.pipe(takeWhile(event => event.event === EVENTS.SeriesAddedToCollection), takeUntil(this.onDestory), debounceTime(2000)).subscribe(event => { + const collectionEvent = event.payload as SeriesAddedToCollectionEvent; + if (collectionEvent.tagId === this.collectionTag.id) { + this.loadPage(); + } + }); + } + + ngOnDestroy() { + this.onDestory.next(); + this.onDestory.complete(); } @HostListener('document:keydown.shift', ['$event']) diff --git a/UI/Web/src/app/home/home.component.html b/UI/Web/src/app/home/home.component.html deleted file mode 100644 index 9b23d9c1b..000000000 --- a/UI/Web/src/app/home/home.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
      - -

      Please create an admin account for yourself to start your reading journey.

      - -
      -
      - - - - - diff --git a/UI/Web/src/app/home/home.component.scss b/UI/Web/src/app/home/home.component.scss deleted file mode 100644 index e69de29bb..000000000 diff --git a/UI/Web/src/app/home/home.component.ts b/UI/Web/src/app/home/home.component.ts deleted file mode 100644 index 7c474cf82..000000000 --- a/UI/Web/src/app/home/home.component.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { Component, OnInit } from '@angular/core'; -import { FormControl, FormGroup, Validators } from '@angular/forms'; -import { Router } from '@angular/router'; -import { take } from 'rxjs/operators'; -import { MemberService } from '../_services/member.service'; -import { AccountService } from '../_services/account.service'; -import { Title } from '@angular/platform-browser'; - -@Component({ - selector: 'app-home', - templateUrl: './home.component.html', - styleUrls: ['./home.component.scss'] -}) -export class HomeComponent implements OnInit { - - firstTimeFlow = false; - model: any = {}; - registerForm: FormGroup = new FormGroup({ - username: new FormControl('', [Validators.required]), - password: new FormControl('', [Validators.required]) - }); - - constructor(public accountService: AccountService, private memberService: MemberService, private router: Router, private titleService: Title) { - } - - ngOnInit(): void { - - this.memberService.adminExists().subscribe(adminExists => { - this.firstTimeFlow = !adminExists; - - if (this.firstTimeFlow) { - return; - } - - this.titleService.setTitle('Kavita'); - this.accountService.currentUser$.pipe(take(1)).subscribe(user => { - if (user) { - this.router.navigateByUrl('/library'); - } else { - this.router.navigateByUrl('/login'); - } - }); - }); - } - - - onAdminCreated(success: boolean) { - if (success) { - this.router.navigateByUrl('/login'); - } - } -} diff --git a/UI/Web/src/app/library-detail/library-detail.component.ts b/UI/Web/src/app/library-detail/library-detail.component.ts index fdd58fb33..57c85d789 100644 --- a/UI/Web/src/app/library-detail/library-detail.component.ts +++ b/UI/Web/src/app/library-detail/library-detail.component.ts @@ -1,10 +1,12 @@ -import { Component, HostListener, OnInit } from '@angular/core'; +import { Component, HostListener, OnDestroy, OnInit } from '@angular/core'; import { Title } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; -import { take } from 'rxjs/operators'; +import { Subject } from 'rxjs'; +import { debounceTime, take, takeUntil, takeWhile } from 'rxjs/operators'; import { BulkSelectionService } from '../cards/bulk-selection.service'; import { UpdateFilterEvent } from '../cards/card-detail-layout/card-detail-layout.component'; import { KEY_CODES } from '../shared/_services/utility.service'; +import { SeriesAddedEvent } from '../_models/events/series-added-event'; import { Library } from '../_models/library'; import { Pagination } from '../_models/pagination'; import { Series } from '../_models/series'; @@ -12,6 +14,7 @@ import { FilterItem, mangaFormatFilters, SeriesFilter } from '../_models/series- import { Action, ActionFactoryService, ActionItem } from '../_services/action-factory.service'; import { ActionService } from '../_services/action.service'; import { LibraryService } from '../_services/library.service'; +import { MessageHubService } from '../_services/message-hub.service'; import { SeriesService } from '../_services/series.service'; @Component({ @@ -19,7 +22,7 @@ import { SeriesService } from '../_services/series.service'; templateUrl: './library-detail.component.html', styleUrls: ['./library-detail.component.scss'] }) -export class LibraryDetailComponent implements OnInit { +export class LibraryDetailComponent implements OnInit, OnDestroy { libraryId!: number; libraryName = ''; @@ -31,6 +34,7 @@ export class LibraryDetailComponent implements OnInit { filter: SeriesFilter = { mangaFormat: null }; + onDestroy: Subject = new Subject(); bulkActionCallback = (action: Action, data: any) => { const selectedSeriesIndexies = this.bulkSelectionService.getSelectedCardsForSource('series'); @@ -60,7 +64,7 @@ export class LibraryDetailComponent implements OnInit { constructor(private route: ActivatedRoute, private router: Router, private seriesService: SeriesService, private libraryService: LibraryService, private titleService: Title, private actionFactoryService: ActionFactoryService, - private actionService: ActionService, public bulkSelectionService: BulkSelectionService) { + private actionService: ActionService, public bulkSelectionService: BulkSelectionService, private hubService: MessageHubService) { const routeId = this.route.snapshot.paramMap.get('id'); if (routeId === null) { this.router.navigateByUrl('/libraries'); @@ -78,7 +82,14 @@ export class LibraryDetailComponent implements OnInit { } ngOnInit(): void { - + this.hubService.seriesAdded.pipe(takeWhile(event => event.libraryId === this.libraryId), debounceTime(6000), takeUntil(this.onDestroy)).subscribe((event: SeriesAddedEvent) => { + this.loadPage(); + }); + } + + ngOnDestroy() { + this.onDestroy.next(); + this.onDestroy.complete(); } @HostListener('document:keydown.shift', ['$event']) diff --git a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts index d40eec83d..d3978b4bf 100644 --- a/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts +++ b/UI/Web/src/app/manga-reader/infinite-scroller/infinite-scroller.component.ts @@ -205,7 +205,7 @@ export class InfiniteScrollerComponent implements OnInit, OnChanges, OnDestroy { return document.documentElement.offsetHeight + document.documentElement.scrollTop; } getScrollTop() { - return document.documentElement.scrollTop + return document.documentElement.scrollTop; } checkIfShouldTriggerContinuousReader() { diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.html b/UI/Web/src/app/manga-reader/manga-reader.component.html index 0a96cadd0..698596fb5 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.html +++ b/UI/Web/src/app/manga-reader/manga-reader.component.html @@ -31,8 +31,8 @@
    -
    -
    +
    +
    diff --git a/UI/Web/src/app/manga-reader/manga-reader.component.ts b/UI/Web/src/app/manga-reader/manga-reader.component.ts index 9f34eb75b..ec9c0ba90 100644 --- a/UI/Web/src/app/manga-reader/manga-reader.component.ts +++ b/UI/Web/src/app/manga-reader/manga-reader.component.ts @@ -12,7 +12,7 @@ import { ScalingOption } from '../_models/preferences/scaling-option'; import { PageSplitOption } from '../_models/preferences/page-split-option'; import { forkJoin, ReplaySubject, Subject } from 'rxjs'; import { ToastrService } from 'ngx-toastr'; -import { KEY_CODES } from '../shared/_services/utility.service'; +import { KEY_CODES, UtilityService } from '../shared/_services/utility.service'; import { CircularArray } from '../shared/data-structures/circular-array'; import { MemberService } from '../_services/member.service'; import { Stack } from '../shared/data-structures/stack'; @@ -23,6 +23,8 @@ import { COLOR_FILTER, FITTING_OPTION, PAGING_DIRECTION, SPLIT_PAGE_PART } from import { Preferences, scalingOptions } from '../_models/preferences/preferences'; import { READER_MODE } from '../_models/preferences/reader-mode'; import { MangaFormat } from '../_models/manga-format'; +import { LibraryService } from '../_services/library.service'; +import { LibraryType } from '../_models/library'; const PREFETCH_PAGES = 5; @@ -201,6 +203,14 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { * A map of bookmarked pages to anything. Used for O(1) lookup time if a page is bookmarked or not. */ bookmarks: {[key: string]: number} = {}; + /** + * Tracks if the first page is rendered or not. This is used to keep track of Automatic Scaling and adjusting decision after first page dimensions load up. + */ + firstPageRendered: boolean = false; + /** + * Library Type used for rendering chapter or issue + */ + libraryType: LibraryType = LibraryType.Manga; private readonly onDestroy = new Subject(); @@ -256,7 +266,8 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { constructor(private route: ActivatedRoute, private router: Router, private accountService: AccountService, public readerService: ReaderService, private location: Location, private formBuilder: FormBuilder, private navService: NavService, - private toastr: ToastrService, private memberService: MemberService) { + private toastr: ToastrService, private memberService: MemberService, + private libraryService: LibraryService, private utilityService: UtilityService) { this.navService.hideNavBar(); } @@ -321,7 +332,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { }); } else { // If no user, we can't render - this.router.navigateByUrl('/home'); + this.router.navigateByUrl('/login'); } }); @@ -396,7 +407,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { forkJoin({ progress: this.readerService.getProgress(this.chapterId), chapterInfo: this.readerService.getChapterInfo(this.chapterId), - bookmarks: this.readerService.getBookmarks(this.chapterId) + bookmarks: this.readerService.getBookmarks(this.chapterId), }).pipe(take(1)).subscribe(results => { if (this.readingListMode && results.chapterInfo.seriesFormat === MangaFormat.EPUB) { @@ -421,7 +432,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { newOptions.ceil = this.maxPages - 1; // We -1 so that the slider UI shows us hitting the end, since visually we +1 everything. this.pageOptions = newOptions; - this.updateTitle(results.chapterInfo); + this.libraryService.getLibraryType(results.chapterInfo.libraryId).pipe(take(1)).subscribe(type => { + this.libraryType = type; + this.updateTitle(results.chapterInfo, type); + }); + + // From bookmarks, create map of pages to make lookup time O(1) this.bookmarks = {}; @@ -475,7 +491,7 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { } } - updateTitle(chapterInfo: ChapterInfo) { + updateTitle(chapterInfo: ChapterInfo, type: LibraryType) { this.title = chapterInfo.seriesName; if (chapterInfo.chapterTitle.length > 0) { this.title += ' - ' + chapterInfo.chapterTitle; @@ -485,12 +501,12 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { if (chapterInfo.isSpecial && chapterInfo.volumeNumber === '0') { this.subtitle = chapterInfo.fileName; } else if (!chapterInfo.isSpecial && chapterInfo.volumeNumber === '0') { - this.subtitle = 'Chapter ' + chapterInfo.chapterNumber; + this.subtitle = this.utilityService.formatChapterName(type, true, true) + chapterInfo.chapterNumber; } else { this.subtitle = 'Volume ' + chapterInfo.volumeNumber; if (chapterInfo.chapterNumber !== '0') { - this.subtitle += ' Chapter ' + chapterInfo.chapterNumber; + this.subtitle += ' ' + this.utilityService.formatChapterName(type, true, true) + chapterInfo.chapterNumber; } } } @@ -760,10 +776,10 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { const newRoute = this.readerService.getNextChapterUrl(this.router.url, this.chapterId, this.incognitoMode, this.readingListMode, this.readingListId); window.history.replaceState({}, '', newRoute); this.init(); - this.toastr.info(direction + ' chapter loaded', '', {timeOut: 3000}); + this.toastr.info(direction + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase() + ' loaded', '', {timeOut: 3000}); } else { // This will only happen if no actual chapter can be found - this.toastr.warning('Could not find ' + direction.toLowerCase() + ' chapter'); + this.toastr.warning('Could not find ' + direction.toLowerCase() + ' ' + this.utilityService.formatChapterName(this.libraryType).toLowerCase()); this.isLoading = false; if (direction === 'Prev') { this.prevPageDisabled = true; @@ -822,6 +838,30 @@ export class MangaReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.canvas.nativeElement.width = this.canvasImage.width / 2; this.ctx.drawImage(this.canvasImage, 0, 0, this.canvasImage.width, this.canvasImage.height, -this.canvasImage.width / 2, 0, this.canvasImage.width, this.canvasImage.height); } else { + if (!this.firstPageRendered && this.scalingOption === ScalingOption.Automatic) { + + let newScale = this.generalSettingsForm.get('fittingOption')?.value; + const windowWidth = window.innerWidth + || document.documentElement.clientWidth + || document.body.clientWidth; + const windowHeight = window.innerHeight + || document.documentElement.clientHeight + || document.body.clientHeight; + + const widthRatio = windowWidth / this.canvasImage.width; + const heightRatio = windowHeight / this.canvasImage.height; + + // Given that we now have image dimensions, assuming this isn't a split image, + // Try to reset one time based on who's dimension (width/height) is smaller + if (widthRatio < heightRatio) { + newScale = FITTING_OPTION.WIDTH; + } else if (widthRatio > heightRatio) { + newScale = FITTING_OPTION.HEIGHT; + } + + this.generalSettingsForm.get('fittingOption')?.setValue(newScale); + this.firstPageRendered = true; + } this.ctx.drawImage(this.canvasImage, 0, 0); } // Reset scroll on non HEIGHT Fits diff --git a/UI/Web/src/app/nav-header/nav-header.component.html b/UI/Web/src/app/nav-header/nav-header.component.html index c371393d3..87bc92ec5 100644 --- a/UI/Web/src/app/nav-header/nav-header.component.html +++ b/UI/Web/src/app/nav-header/nav-header.component.html @@ -63,7 +63,7 @@ \ No newline at end of file +
    diff --git a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts index 5c1728157..fb4c3a709 100644 --- a/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/reading-list-detail/reading-list-detail.component.ts @@ -4,6 +4,7 @@ import { ToastrService } from 'ngx-toastr'; import { take } from 'rxjs/operators'; import { ConfirmService } from 'src/app/shared/confirm.service'; import { UtilityService } from 'src/app/shared/_services/utility.service'; +import { LibraryType } from 'src/app/_models/library'; import { MangaFormat } from 'src/app/_models/manga-format'; import { ReadingList, ReadingListItem } from 'src/app/_models/reading-list'; import { AccountService } from 'src/app/_services/account.service'; @@ -12,6 +13,8 @@ import { ActionService } from 'src/app/_services/action.service'; import { ImageService } from 'src/app/_services/image.service'; import { ReadingListService } from 'src/app/_services/reading-list.service'; import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/dragable-ordered-list.component'; +import { LibraryService } from '../../_services/library.service'; +import { forkJoin } from 'rxjs'; @Component({ selector: 'app-reading-list-detail', @@ -19,7 +22,6 @@ import { IndexUpdateEvent, ItemRemoveEvent } from '../dragable-ordered-list/drag styleUrls: ['./reading-list-detail.component.scss'] }) export class ReadingListDetailComponent implements OnInit { - items: Array = []; listId!: number; readingList!: ReadingList; @@ -32,6 +34,7 @@ export class ReadingListDetailComponent implements OnInit { hasDownloadingRole: boolean = false; downloadInProgress: boolean = false; + libraryTypes: {[key: number]: LibraryType} = {}; get MangaFormat(): typeof MangaFormat { return MangaFormat; @@ -39,7 +42,8 @@ export class ReadingListDetailComponent implements OnInit { constructor(private route: ActivatedRoute, private router: Router, private readingListService: ReadingListService, private actionService: ActionService, private actionFactoryService: ActionFactoryService, public utilityService: UtilityService, - public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService, private confirmService: ConfirmService) {} + public imageService: ImageService, private accountService: AccountService, private toastr: ToastrService, + private confirmService: ConfirmService, private libraryService: LibraryService) {} ngOnInit(): void { const listId = this.route.snapshot.paramMap.get('id'); @@ -51,7 +55,21 @@ export class ReadingListDetailComponent implements OnInit { this.listId = parseInt(listId, 10); - this.readingListService.getReadingList(this.listId).subscribe(readingList => { + this.libraryService.getLibraries().subscribe(libs => { + + }); + + forkJoin([ + this.libraryService.getLibraries(), + this.readingListService.getReadingList(this.listId) + ]).subscribe(results => { + const libraries = results[0]; + const readingList = results[1]; + + libraries.forEach(lib => { + this.libraryTypes[lib.id] = lib.type; + }); + if (readingList == null) { // The list doesn't exist this.toastr.error('This list doesn\'t exist.'); @@ -81,7 +99,6 @@ export class ReadingListDetailComponent implements OnInit { } performAction(action: ActionItem) { - // TODO: Try to move performAction into the actionables component. (have default handler in the component, allow for overridding to pass additional context) if (typeof action.callback === 'function') { action.callback(action.action, this.readingList); } @@ -119,7 +136,7 @@ export class ReadingListDetailComponent implements OnInit { return 'Volume ' + this.utilityService.cleanSpecialTitle(item.chapterNumber); } - return 'Chapter ' + item.chapterNumber; + return this.utilityService.formatChapterName(this.libraryTypes[item.libraryId], true, true) + item.chapterNumber; } orderUpdated(event: IndexUpdateEvent) { diff --git a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts index d7051df2e..9c6cc149c 100644 --- a/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts +++ b/UI/Web/src/app/reading-list/reading-lists/reading-lists.component.ts @@ -41,7 +41,6 @@ export class ReadingListsComponent implements OnInit { } performAction(action: ActionItem, readingList: ReadingList) { - // TODO: Try to move performAction into the actionables component. (have default handler in the component, allow for overridding to pass additional context) if (typeof action.callback === 'function') { action.callback(action.action, readingList); } diff --git a/UI/Web/src/app/register-member/register-member.component.html b/UI/Web/src/app/register-member/register-member.component.html index 155b0e9a9..84a7983a8 100644 --- a/UI/Web/src/app/register-member/register-member.component.html +++ b/UI/Web/src/app/register-member/register-member.component.html @@ -1,3 +1,4 @@ +

    Errors:

      @@ -10,7 +11,7 @@
    -
    +
    @@ -21,7 +22,7 @@
    - - + +
    - \ No newline at end of file + diff --git a/UI/Web/src/app/register-member/register-member.component.scss b/UI/Web/src/app/register-member/register-member.component.scss index e69de29bb..485525296 100644 --- a/UI/Web/src/app/register-member/register-member.component.scss +++ b/UI/Web/src/app/register-member/register-member.component.scss @@ -0,0 +1,17 @@ +.alt { + background-color: #424c72; + border-color: #444f75; +} + +.alt:hover { + background-color: #3b4466; +} + +.alt:focus { + background-color: #343c59; + box-shadow: 0 0 0 0.2rem rgb(68 79 117 / 50%); +} + +input { + background-color: #fff !important; +} \ No newline at end of file diff --git a/UI/Web/src/app/register-member/register-member.component.ts b/UI/Web/src/app/register-member/register-member.component.ts index 9b839b3aa..8a705700c 100644 --- a/UI/Web/src/app/register-member/register-member.component.ts +++ b/UI/Web/src/app/register-member/register-member.component.ts @@ -1,6 +1,9 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { FormGroup, FormControl, Validators } from '@angular/forms'; +import { take } from 'rxjs/operators'; import { AccountService } from 'src/app/_services/account.service'; +import { SettingsService } from '../admin/settings.service'; +import { User } from '../_models/user'; @Component({ selector: 'app-register-member', @@ -10,35 +13,42 @@ import { AccountService } from 'src/app/_services/account.service'; export class RegisterMemberComponent implements OnInit { @Input() firstTimeFlow = false; - @Output() created = new EventEmitter(); + /** + * Emits the new user created. + */ + @Output() created = new EventEmitter(); adminExists = false; + authDisabled: boolean = false; registerForm: FormGroup = new FormGroup({ username: new FormControl('', [Validators.required]), - password: new FormControl('', [Validators.required]), + password: new FormControl('', []), isAdmin: new FormControl(false, []) }); errors: string[] = []; - constructor(private accountService: AccountService) { + constructor(private accountService: AccountService, private settingsService: SettingsService) { } ngOnInit(): void { + this.settingsService.getAuthenticationEnabled().pipe(take(1)).subscribe(authEnabled => { + this.authDisabled = !authEnabled; + }); if (this.firstTimeFlow) { this.registerForm.get('isAdmin')?.setValue(true); } } register() { - this.accountService.register(this.registerForm.value).subscribe(resp => { - this.created.emit(true); + this.accountService.register(this.registerForm.value).subscribe(user => { + this.created.emit(user); }, err => { this.errors = err; }); } cancel() { - this.created.emit(false); + this.created.emit(null); } } diff --git a/UI/Web/src/app/series-detail/series-detail.component.html b/UI/Web/src/app/series-detail/series-detail.component.html index 541ff5b88..a80694687 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.html +++ b/UI/Web/src/app/series-detail/series-detail.component.html @@ -112,7 +112,7 @@
  • - Volumes/Chapters + {{utilityService.formatChapterName(libraryType) + 's'}}
    @@ -121,7 +121,7 @@ [read]="volume.pagesRead" [total]="volume.pages" [actions]="volumeActions" (selection)="bulkSelectionService.handleCardSelection('volume', idx, volumes.length, $event)" [selected]="bulkSelectionService.isCardSelected('volume', idx)" [allowSelection]="true">
    -
    diff --git a/UI/Web/src/app/series-detail/series-detail.component.ts b/UI/Web/src/app/series-detail/series-detail.component.ts index 208bbd363..65e99ebc0 100644 --- a/UI/Web/src/app/series-detail/series-detail.component.ts +++ b/UI/Web/src/app/series-detail/series-detail.component.ts @@ -259,7 +259,7 @@ export class SeriesDetailComponent implements OnInit, OnDestroy { break; case(Action.IncognitoRead): if (volume.chapters != undefined && volume.chapters?.length >= 1) { - this.openChapter(volume.chapters[0], true); + this.openChapter(volume.chapters.sort(this.utilityService.sortChapters)[0], true); } break; default: diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index b66f67cb9..95751885e 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@angular/core'; import { Chapter } from 'src/app/_models/chapter'; +import { LibraryType } from 'src/app/_models/library'; import { MangaFormat } from 'src/app/_models/manga-format'; import { Series } from 'src/app/_models/series'; import { Volume } from 'src/app/_models/volume'; @@ -56,6 +57,27 @@ export class UtilityService { return this.mangaFormatKeys.filter(item => MangaFormat[format] === item)[0]; } + /** + * Formats a Chapter name based on the library it's in + * @param libraryType + * @param includeHash For comics only, includes a # which is used for numbering on cards + * @param includeSpace Add a space at the end of the string. if includeHash and includeSpace are true, only hash will be at the end. + * @returns + */ + formatChapterName(libraryType: LibraryType, includeHash: boolean = false, includeSpace: boolean = false) { + switch(libraryType) { + case LibraryType.Book: + return 'Book' + (includeSpace ? ' ' : ''); + case LibraryType.Comic: + if (includeHash) { + return 'Issue #'; + } + return 'Issue' + (includeSpace ? ' ' : ''); + case LibraryType.Manga: + return 'Chapter' + (includeSpace ? ' ' : ''); + } + } + cleanSpecialTitle(title: string) { let cleaned = title.replace(/_/g, ' ').replace(/SP\d+/g, '').trim(); cleaned = cleaned.substring(0, cleaned.lastIndexOf('.')); @@ -127,4 +149,14 @@ export class UtilityService { return Breakpoint.Desktop; } + isInViewport(element: Element, additionalTopOffset: number = 0) { + const rect = element.getBoundingClientRect(); + return ( + rect.top >= additionalTopOffset && + rect.left >= 0 && + rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && + rect.right <= (window.innerWidth || document.documentElement.clientWidth) + ); + } + } diff --git a/UI/Web/src/app/shared/drawer/drawer.component.scss b/UI/Web/src/app/shared/drawer/drawer.component.scss index f2b1476f3..6e33efda4 100644 --- a/UI/Web/src/app/shared/drawer/drawer.component.scss +++ b/UI/Web/src/app/shared/drawer/drawer.component.scss @@ -13,7 +13,7 @@ height: 100vh; background: var(--drawer-background-color); transition: all 300ms; - box-shadow: -3px 0px 6px 1px #00000026; + box-shadow: 0 6px 4px 2px rgb(0 0 0 / 70%); padding: 10px 10px; z-index: 1021; overflow: auto; diff --git a/UI/Web/src/app/shared/sentence-case.pipe.ts b/UI/Web/src/app/shared/sentence-case.pipe.ts new file mode 100644 index 000000000..49930a6d5 --- /dev/null +++ b/UI/Web/src/app/shared/sentence-case.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'sentenceCase' +}) +export class SentenceCasePipe implements PipeTransform { + + transform(value: string | null): string { + if (value === null || value === undefined) return ''; + + return value.charAt(0).toUpperCase() + value.substr(1); + } + +} diff --git a/UI/Web/src/app/shared/shared.module.ts b/UI/Web/src/app/shared/shared.module.ts index 246d9363c..efd0dc009 100644 --- a/UI/Web/src/app/shared/shared.module.ts +++ b/UI/Web/src/app/shared/shared.module.ts @@ -15,6 +15,7 @@ import { SeriesFormatComponent } from './series-format/series-format.component'; import { UpdateNotificationModalComponent } from './update-notification/update-notification-modal.component'; import { CircularLoaderComponent } from './circular-loader/circular-loader.component'; import { NgCircleProgressModule } from 'ng-circle-progress'; +import { SentenceCasePipe } from './sentence-case.pipe'; @NgModule({ declarations: [ @@ -29,6 +30,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress'; SeriesFormatComponent, UpdateNotificationModalComponent, CircularLoaderComponent, + SentenceCasePipe, ], imports: [ CommonModule, @@ -40,6 +42,7 @@ import { NgCircleProgressModule } from 'ng-circle-progress'; exports: [ RegisterMemberComponent, SafeHtmlPipe, + SentenceCasePipe, ReadMoreComponent, DrawerComponent, TagBadgeComponent, diff --git a/UI/Web/src/app/user-login/user-login.component.html b/UI/Web/src/app/user-login/user-login.component.html index 553522c81..9caa02e73 100644 --- a/UI/Web/src/app/user-login/user-login.component.html +++ b/UI/Web/src/app/user-login/user-login.component.html @@ -1,28 +1,46 @@