diff --git a/API.Tests/Parsing/MagazineParserTests.cs b/API.Tests/Parsing/MagazineParserTests.cs new file mode 100644 index 000000000..f6e71d9e0 --- /dev/null +++ b/API.Tests/Parsing/MagazineParserTests.cs @@ -0,0 +1,43 @@ +using Xunit; + +namespace API.Tests.Parser; + +public class MagazineParserTests +{ + [Theory] + [InlineData("3D World - 2018 UK", "3D World")] + [InlineData("3D World - 2018", "3D World")] + [InlineData("UK World - 022012 [Digital]", "UK World")] + [InlineData("Computer Weekly - September 2023", "Computer Weekly")] + public void ParseSeriesTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseMagazineSeries(filename)); + } + + [Theory] + [InlineData("UK World - 022012 [Digital]", "2012")] + [InlineData("Computer Weekly - September 2023", "2023")] + [InlineData("Computer Weekly - September 2023 #2", "2023")] + [InlineData("PC Games - 2001 #01", "2001")] + public void ParseVolumeTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseMagazineVolume(filename)); + } + + [Theory] + [InlineData("UK World - 022012 [Digital]", "0")] + [InlineData("Computer Weekly - September 2023", "9")] + [InlineData("Computer Weekly - September 2023 #2", "2")] + [InlineData("PC Games - 2001 #01", "1")] + public void ParseChapterTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseMagazineChapter(filename)); + } + + [Theory] + [InlineData("AIR International Vol. 14 No. 3 (ISSN 1011-3250)", "1011-3250")] + public void ParseGTINTest(string filename, string expected) + { + Assert.Equal(expected, API.Services.Tasks.Scanner.Parser.Parser.ParseGTIN(filename)); + } +} diff --git a/API/Controllers/BookController.cs b/API/Controllers/BookController.cs index e1d7da9e8..251811346 100644 --- a/API/Controllers/BookController.cs +++ b/API/Controllers/BookController.cs @@ -82,6 +82,7 @@ public class BookController : BaseApiController SeriesFormat = dto.SeriesFormat, SeriesId = dto.SeriesId, LibraryId = dto.LibraryId, + LibraryType = dto.LibraryType, IsSpecial = dto.IsSpecial, Pages = dto.Pages, }); diff --git a/API/Controllers/LibraryController.cs b/API/Controllers/LibraryController.cs index 2f12aa1fe..dbd809cee 100644 --- a/API/Controllers/LibraryController.cs +++ b/API/Controllers/LibraryController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; @@ -654,4 +655,14 @@ public class LibraryController : BaseApiController { return Ok(await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId)); } + + /// + /// Return pairs of all types + /// + /// + [HttpGet("types")] + public async Task>> GetLibraryTypes() + { + return Ok(await _unitOfWork.LibraryRepository.GetLibraryTypesAsync(User.GetUserId())); + } } diff --git a/API/Controllers/ReaderController.cs b/API/Controllers/ReaderController.cs index 38a5ad482..207dbabb5 100644 --- a/API/Controllers/ReaderController.cs +++ b/API/Controllers/ReaderController.cs @@ -246,6 +246,7 @@ public class ReaderController : BaseApiController SeriesFormat = dto.SeriesFormat, SeriesId = dto.SeriesId, LibraryId = dto.LibraryId, + LibraryType = dto.LibraryType, IsSpecial = dto.IsSpecial, Pages = dto.Pages, SeriesTotalPages = series.Pages, @@ -287,6 +288,7 @@ public class ReaderController : BaseApiController return Ok(info); } + /// /// Returns various information about all bookmark files for a Series. Side effect: This will cache the bookmark images for reading. /// diff --git a/API/DTOs/LibraryTypeDto.cs b/API/DTOs/LibraryTypeDto.cs new file mode 100644 index 000000000..9f448e7b7 --- /dev/null +++ b/API/DTOs/LibraryTypeDto.cs @@ -0,0 +1,12 @@ +using API.Entities.Enums; + +namespace API.DTOs; + +/// +/// Simple pairing of LibraryId and LibraryType +/// +public sealed record LibraryTypeDto +{ + public int LibraryId { get; set; } + public LibraryType LibraryType { get; set; } +} diff --git a/API/DTOs/Reader/BookInfoDto.cs b/API/DTOs/Reader/BookInfoDto.cs index 2473cd5dc..5c4e530c6 100644 --- a/API/DTOs/Reader/BookInfoDto.cs +++ b/API/DTOs/Reader/BookInfoDto.cs @@ -15,4 +15,5 @@ public sealed record BookInfoDto : IChapterInfoDto public int Pages { get; set; } public bool IsSpecial { get; set; } public string ChapterTitle { get; set; } = default! ; + public LibraryType LibraryType { get; set; } } diff --git a/API/DTOs/Reader/IChapterInfoDto.cs b/API/DTOs/Reader/IChapterInfoDto.cs index 6a9a74a2c..568adf345 100644 --- a/API/DTOs/Reader/IChapterInfoDto.cs +++ b/API/DTOs/Reader/IChapterInfoDto.cs @@ -14,5 +14,6 @@ public interface IChapterInfoDto public int Pages { get; set; } public bool IsSpecial { get; set; } public string ChapterTitle { get; set; } + public LibraryType LibraryType { get; set; } } diff --git a/API/Data/Repositories/ChapterRepository.cs b/API/Data/Repositories/ChapterRepository.cs index 27d21df74..ce7c44baa 100644 --- a/API/Data/Repositories/ChapterRepository.cs +++ b/API/Data/Repositories/ChapterRepository.cs @@ -137,11 +137,11 @@ public class ChapterRepository : IChapterRepository LibraryId = data.LibraryId, Pages = data.Pages, ChapterTitle = data.TitleName, - LibraryType = data.LibraryType + LibraryType = data.LibraryType, }) .AsNoTracking() .AsSplitQuery() - .SingleOrDefaultAsync(); + .FirstOrDefaultAsync(); return chapterInfo; } diff --git a/API/Data/Repositories/LibraryRepository.cs b/API/Data/Repositories/LibraryRepository.cs index 78022fa9a..b3f905dce 100644 --- a/API/Data/Repositories/LibraryRepository.cs +++ b/API/Data/Repositories/LibraryRepository.cs @@ -58,6 +58,7 @@ public interface ILibraryRepository Task GetAllowsScrobblingBySeriesId(int seriesId); Task> GetLibraryTypesBySeriesIdsAsync(IList seriesIds); + Task> GetLibraryTypesAsync(int userId); } public class LibraryRepository : ILibraryRepository @@ -369,4 +370,17 @@ public class LibraryRepository : ILibraryRepository }) .ToDictionaryAsync(entity => entity.Id, entity => entity.Type); } + + public async Task> GetLibraryTypesAsync(int userId) + { + return await _context.Library + .Where(l => l.AppUsers.Any(u => u.Id == userId)) + .Select(l => new LibraryTypeDto() + { + LibraryType = l.Type, + LibraryId = l.Id + }) + .AsSplitQuery() + .ToListAsync(); + } } diff --git a/API/Entities/Enums/LibraryType.cs b/API/Entities/Enums/LibraryType.cs index a8d943b2d..b79315803 100644 --- a/API/Entities/Enums/LibraryType.cs +++ b/API/Entities/Enums/LibraryType.cs @@ -34,4 +34,9 @@ public enum LibraryType /// [Description("Comic")] ComicVine = 5, + /// + /// Uses Magazine regex and is restricted to PDF and Archive by default + /// + [Description("Magazine")] + Magazine = 6 } diff --git a/API/Services/ReadingItemService.cs b/API/Services/ReadingItemService.cs index efdaec8ff..464c3b33c 100644 --- a/API/Services/ReadingItemService.cs +++ b/API/Services/ReadingItemService.cs @@ -27,6 +27,7 @@ public class ReadingItemService : IReadingItemService private readonly ImageParser _imageParser; private readonly BookParser _bookParser; private readonly PdfParser _pdfParser; + private readonly MagazineParser _magazineParser; public ReadingItemService(IArchiveService archiveService, IBookService bookService, IImageService imageService, IDirectoryService directoryService, ILogger logger) @@ -42,6 +43,7 @@ public class ReadingItemService : IReadingItemService _bookParser = new BookParser(directoryService, bookService, _basicParser); _comicVineParser = new ComicVineParser(directoryService); _pdfParser = new PdfParser(directoryService); + _magazineParser = new MagazineParser(directoryService); } @@ -189,6 +191,10 @@ public class ReadingItemService : IReadingItemService { return _bookParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); } + if (_magazineParser.IsApplicable(path, type)) + { + return _magazineParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); + } if (_pdfParser.IsApplicable(path, type)) { return _pdfParser.Parse(path, rootPath, libraryRoot, type, GetComicInfo(path)); diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 805b3b06f..9b06f7e51 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -33,7 +33,6 @@ public interface ISeriesService Task UpdateRelatedSeries(UpdateRelatedSeriesDto dto); Task GetRelatedSeries(int userId, int seriesId); Task FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true); - Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true); Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash); Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false); @@ -633,7 +632,7 @@ public class SeriesService : ISeriesService public async Task FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle, bool withHash) { - if (string.IsNullOrEmpty(chapterTitle) && (isSpecial || libraryType == LibraryType.Book)) throw new ArgumentException("Chapter Title cannot be null"); + if (string.IsNullOrEmpty(chapterTitle) && (isSpecial || (libraryType == LibraryType.Book || libraryType == LibraryType.Magazine))) throw new ArgumentException("Chapter Title cannot be null"); if (isSpecial) { @@ -643,9 +642,10 @@ public class SeriesService : ISeriesService var hashSpot = withHash ? "#" : string.Empty; var baseChapter = libraryType switch { - LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle!), + LibraryType.Book => await _localizationService.Translate(userId, "book-num", chapterTitle ?? string.Empty), LibraryType.LightNovel => await _localizationService.Translate(userId, "book-num", chapterRange), LibraryType.Comic => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange), + LibraryType.Magazine => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterTitle ?? string.Empty), LibraryType.ComicVine => await _localizationService.Translate(userId, "issue-num", hashSpot, chapterRange), LibraryType.Manga => await _localizationService.Translate(userId, "chapter-num", chapterRange), LibraryType.Image => await _localizationService.Translate(userId, "chapter-num", chapterRange), @@ -666,10 +666,6 @@ public class SeriesService : ISeriesService return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash); } - public async Task FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true) - { - return await FormatChapterTitle(userId, chapter.IsSpecial, libraryType, chapter.Range, chapter.Title, withHash); - } // TODO: Refactor this out and use FormatChapterTitle instead across library public async Task FormatChapterName(int userId, LibraryType libraryType, bool withHash = false) diff --git a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs index b68596245..f632bcd59 100644 --- a/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs +++ b/API/Services/Tasks/Scanner/Parser/ComicVineParser.cs @@ -57,7 +57,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser { if (!Parser.IsSeriesAndYear(directory)) continue; info.Series = directory; - info.Volumes = Parser.ParseYear(directory); + info.Volumes = Parser.ParseYearFromSeries(directory); break; } @@ -72,7 +72,7 @@ public class ComicVineParser(IDirectoryService directoryService) : DefaultParser if (Parser.IsSeriesAndYear(directoryName)) { info.Series = directoryName; - info.Volumes = Parser.ParseYear(directoryName); + info.Volumes = Parser.ParseYearFromSeries(directoryName); } } } diff --git a/API/Services/Tasks/Scanner/Parser/MagazineParser.cs b/API/Services/Tasks/Scanner/Parser/MagazineParser.cs new file mode 100644 index 000000000..1aa0ff19d --- /dev/null +++ b/API/Services/Tasks/Scanner/Parser/MagazineParser.cs @@ -0,0 +1,86 @@ +using System.IO; +using System.Linq; +using API.Data.Metadata; +using API.Entities.Enums; + +namespace API.Services.Tasks.Scanner.Parser; +#nullable enable + +public class MagazineParser(IDirectoryService directoryService) : DefaultParser(directoryService) +{ + public override ParserInfo? Parse(string filePath, string rootPath, string libraryRoot, LibraryType type, + ComicInfo? comicInfo = null) + { + if (!IsApplicable(filePath, type)) return null; + + var ret = new ParserInfo + { + Volumes = Parser.LooseLeafVolume, + Chapters = Parser.DefaultChapter, + ComicInfo = comicInfo, + Format = Parser.ParseFormat(filePath), + Filename = Path.GetFileName(filePath), + FullFilePath = Parser.NormalizePath(filePath), + Series = string.Empty, + }; + + // Try to parse Series from the filename + var libraryPath = directoryService.FileSystem.DirectoryInfo.New(rootPath).Parent?.FullName ?? rootPath; + var fileName = directoryService.FileSystem.Path.GetFileNameWithoutExtension(filePath); + ret.Series = Parser.ParseMagazineSeries(fileName); + ret.Volumes = Parser.ParseMagazineVolume(fileName); + ret.Chapters = Parser.ParseMagazineChapter(fileName); + + if (string.IsNullOrEmpty(ret.Series) || (string.IsNullOrEmpty(ret.Chapters) && string.IsNullOrEmpty(ret.Volumes))) + { + // Fallback to the parent folder. We can also likely grab Volume (year) from here + var folders = directoryService.GetFoldersTillRoot(libraryPath, filePath).ToList(); + // Usually the LAST folder is the Series and everything up to can have Volume + + + if (string.IsNullOrEmpty(ret.Series)) + { + ret.Series = Parser.CleanTitle(folders[^1]); + } + + var hasGeoCode = !string.IsNullOrEmpty(Parser.ParseGeoCode(ret.Series)); + foreach (var folder in folders[..^1]) + { + if (ret.Volumes == Parser.LooseLeafVolume) + { + var vol = Parser.ParseYear(folder); // TODO: This might be better as YearFromSeries + if (!string.IsNullOrEmpty(vol) && vol != folder) + { + ret.Volumes = vol; + } + } + + // If folder has a language code in it, then we add that to the Series (Wired (UK)) + if (!hasGeoCode) + { + var geoCode = Parser.ParseGeoCode(folder); + if (!string.IsNullOrEmpty(geoCode)) + { + ret.Series = $"{ret.Series} ({geoCode})"; + hasGeoCode = true; + } + } + + } + } + + return ret; + } + + /// + /// Only applicable for PDF Files and Magazine library type + /// + /// + /// + /// + public override bool IsApplicable(string filePath, LibraryType type) + { + return type == LibraryType.Magazine && Parser.IsPdf(filePath); + } + +} diff --git a/API/Services/Tasks/Scanner/Parser/Parser.cs b/API/Services/Tasks/Scanner/Parser/Parser.cs index 12987b18b..84b723fa9 100644 --- a/API/Services/Tasks/Scanner/Parser/Parser.cs +++ b/API/Services/Tasks/Scanner/Parser/Parser.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; @@ -64,6 +66,8 @@ public static partial class Parser /// private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(? /// Matches against font-family css syntax. Does not match if url import has data: starting, as that is binary data @@ -667,6 +671,69 @@ public static partial class Parser MatchOptions, RegexTimeout ); + #region Magazine + + private static readonly HashSet GeoCodes = new(CreateCountryCodes()); + private static readonly Dictionary MonthMappings = CreateMonthMappings(); + private static readonly Regex[] MagazineSeriesRegex = + [ + // 3D World - 2018 UK, 3D World - 022014 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*\d{4,6}.*", + MatchOptions, RegexTimeout), + // AIR International - April 2018 UK + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*.*", + MatchOptions, RegexTimeout), + // AIR International #1 // This breaks the way the code works + // new Regex( + // @"^(?.+?)(_|\s)+?#", + // MatchOptions, RegexTimeout) + // The New Yorker - April 2, 2018 USA + // AIR International Magazine 2006 + // AIR International Vol. 14 No. 3 (ISSN 1011-3250) + ]; + + private static readonly Regex[] MagazineVolumeRegex = new[] + { + // 3D World - 2018 UK, 3D World - 022014 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*\d{2}?(?\d{4}).*", + MatchOptions, RegexTimeout), + // 3D World - Sept 2018 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*\D+(?\d{4}).*", + MatchOptions, RegexTimeout), + // 3D World - Sept 2018 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*\D+(?\d{4}).*", + MatchOptions, RegexTimeout), + + }; + + private static readonly Regex[] MagazineChapterRegex = new[] + { + // 3D World - September 2023 #2 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*.*#(?\d+).*", + MatchOptions, RegexTimeout), + // Computer Weekly - September 2023 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*(?January|February|March|April|May|June|July|August|September|October|November|December).*", + MatchOptions, RegexTimeout), + // Computer Weekly - Sept 2023 + new Regex( + @"^(?.+?)(_|\s)*-(_|\s)*(?Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sept|Oct|Nov|Dec).*", + MatchOptions, RegexTimeout), + }; + + private static readonly Regex YearRegex = new( + @"(\b|\s|_)[1-9]{1}\d{3}(\b|\s|_)", + MatchOptions, RegexTimeout + ); + + #endregion + public static MangaFormat ParseFormat(string filePath) @@ -739,6 +806,20 @@ public static partial class Parser return string.Empty; } + public static string ParseMagazineSeries(string filename) + { + foreach (var regex in MagazineSeriesRegex) + { + var matches = regex.Matches(filename); + var group = matches + .Select(match => match.Groups["Series"]) + .FirstOrDefault(group => group.Success && group != Match.Empty); + if (group != null) return CleanTitle(group.Value, true); + } + + return string.Empty; + } + public static string ParseMangaVolume(string filename) { foreach (var regex in MangaVolumeRegex) @@ -776,6 +857,137 @@ public static partial class Parser } + public static string ParseMagazineVolume(string filename) + { + foreach (var regex in MagazineVolumeRegex) + { + var matches = regex.Matches(filename); + foreach (var group in matches.Select(match => match.Groups)) + { + if (!group["Volume"].Success || group["Volume"] == Match.Empty) continue; + + var value = group["Volume"].Value; + return FormatValue(value, false); + } + } + + return LooseLeafVolume; + } + + private static string[] CreateCountryCodes() + { + var codes = CultureInfo.GetCultures(CultureTypes.SpecificCultures) + .Select(culture => new RegionInfo(culture.Name).TwoLetterISORegionName) + .Distinct() + .OrderBy(code => code) + .ToList(); + codes.Add("UK"); + return codes.ToArray(); + } + + + private static Dictionary CreateMonthMappings() + { + Dictionary mappings = new(StringComparer.OrdinalIgnoreCase); + + // Add English month names and shorthands + for (var i = 1; i <= 12; i++) + { + var month = new DateTime(2022, i, 1); + var monthName = month.ToString("MMMM", CultureInfo.InvariantCulture); + var monthAbbreviation = month.ToString("MMM", CultureInfo.InvariantCulture); + + mappings[monthName] = i; + mappings[monthAbbreviation] = i; + } + + // Add mappings for other languages if needed + + return mappings; + } + + public static string ParseMagazineChapter(string filename) + { + foreach (var regex in MagazineChapterRegex) + { + var matches = regex.Matches(filename); + foreach (var groups in matches.Select(match => match.Groups)) + { + if (!groups["Chapter"].Success || groups["Chapter"] == Match.Empty) continue; + + var value = groups["Chapter"].Value; + // If value has non-digits, we need to convert to a digit + if (IsNumberRegex().IsMatch(value)) return FormatValue(value, false); + if (MonthMappings.TryGetValue(value, out var parsedMonth)) + { + return FormatValue($"{parsedMonth}", false); + } + } + } + + return DefaultChapter; + } + + /// + /// Tries to parse a GeoCode (UK, US) out of a string + /// + /// + /// + public static string? ParseGeoCode(string? value) + { + if (string.IsNullOrEmpty(value)) return value; + const string pattern = @"\b(?:\(|\[|\{)([A-Z]{2})(?:\)|\]|\})\b|^([A-Z]{2})$"; + + // Match the pattern in the input string + var match = Regex.Match(value, pattern, RegexOptions.IgnoreCase); + + if (match.Success) + { + // Extract the GeoCode from the first capturing group if it exists, + // otherwise, extract the GeoCode from the second capturing group + var extractedCode = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + + // Validate the extracted GeoCode against the list of valid GeoCodes + if (GeoCodes.Contains(extractedCode)) + { + return extractedCode; + } + } + + return null; + } + + + // /// + // /// Tries to parse a GTIN/ISBN out of a string + // /// + // /// + // /// + // public static string? ParseGTIN(string? value) + // { + // if (string.IsNullOrEmpty(value)) return value; + // const string pattern = @"\b(?:\(|\[|\{)([A-Z]{2})(?:\)|\]|\})\b|^([A-Z]{2})$"; + // + // // Match the pattern in the input string + // var match = Regex.Match(value, pattern, RegexOptions.IgnoreCase); + // + // if (match.Success) + // { + // // Extract the GeoCode from the first capturing group if it exists, + // // otherwise, extract the GeoCode from the second capturing group + // var extractedCode = match.Groups[1].Success ? match.Groups[1].Value : match.Groups[2].Value; + // + // // Validate the extracted GeoCode against the list of valid GeoCodes + // if (GeoCodes.Contains(extractedCode)) + // { + // return extractedCode; + // } + // } + // + // return null; + // } + + private static string FormatValue(string value, bool hasPart) { if (!value.Contains('-')) @@ -1159,13 +1371,21 @@ public static partial class Parser return !string.IsNullOrEmpty(name) && SeriesAndYearRegex.IsMatch(name); } - public static string ParseYear(string? name) + /// + /// Extracts year from Series (Year) + /// + /// + /// + public static string ParseYearFromSeries(string? name) { if (string.IsNullOrEmpty(name)) return string.Empty; var match = SeriesAndYearRegex.Match(name); - if (!match.Success) return string.Empty; + return !match.Success ? string.Empty : match.Groups["Year"].Value; + } - return match.Groups["Year"].Value; + public static string ParseYear(string? value) + { + return string.IsNullOrEmpty(value) ? string.Empty : YearRegex.Match(value).Value; } public static string? RemoveExtensionIfSupported(string? filename) diff --git a/UI/Web/src/app/_models/library/library.ts b/UI/Web/src/app/_models/library/library.ts index 74cabc658..000723568 100644 --- a/UI/Web/src/app/_models/library/library.ts +++ b/UI/Web/src/app/_models/library/library.ts @@ -6,10 +6,11 @@ export enum LibraryType { Book = 2, Images = 3, LightNovel = 4, - ComicVine = 5 + ComicVine = 5, + Magazine = 6 } -export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images]; +export const allLibraryTypes = [LibraryType.Manga, LibraryType.ComicVine, LibraryType.Comic, LibraryType.Book, LibraryType.LightNovel, LibraryType.Images, LibraryType.Magazine]; export interface Library { id: number; diff --git a/UI/Web/src/app/_pipes/library-type.pipe.ts b/UI/Web/src/app/_pipes/library-type.pipe.ts index 1881b64d5..784089e6e 100644 --- a/UI/Web/src/app/_pipes/library-type.pipe.ts +++ b/UI/Web/src/app/_pipes/library-type.pipe.ts @@ -26,6 +26,8 @@ export class LibraryTypePipe implements PipeTransform { return this.translocoService.translate('library-type-pipe.manga'); case LibraryType.LightNovel: return this.translocoService.translate('library-type-pipe.lightNovel'); + case LibraryType.Magazine: + return this.translocoService.translate('library-type-pipe.magazine'); default: return ''; } diff --git a/UI/Web/src/app/_services/library.service.ts b/UI/Web/src/app/_services/library.service.ts index 8c851dd80..9178cd137 100644 --- a/UI/Web/src/app/_services/library.service.ts +++ b/UI/Web/src/app/_services/library.service.ts @@ -136,6 +136,20 @@ export class LibraryService { return this.httpClient.post(this.baseUrl + 'library/update', model); } + getLibraryTypes() { + if (this.libraryTypes) return of(this.libraryTypes); + return this.httpClient.get>(this.baseUrl + 'library/types').pipe(map(types => { + if (this.libraryTypes === undefined) { + this.libraryTypes = {}; + } + types.forEach(t => { + this.libraryTypes![t.libraryId] = t.libraryType; + }); + + return this.libraryTypes; + })); + } + getLibraryType(libraryId: number) { if (this.libraryTypes != undefined && this.libraryTypes.hasOwnProperty(libraryId)) { return of(this.libraryTypes[libraryId]); diff --git a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts index 6abd619f8..77b88d9db 100644 --- a/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts +++ b/UI/Web/src/app/book-reader/_components/book-reader/book-reader.component.ts @@ -647,12 +647,19 @@ export class BookReaderComponent implements OnInit, AfterViewInit, OnDestroy { this.bookService.getBookInfo(this.chapterId).subscribe(info => { - if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { - // Redirect to the manga reader. - const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); - this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); - return; - } + + this.libraryService.getLibraryType(this.libraryId).pipe(take(1)).subscribe(type => { + this.libraryType = type; + this.cdRef.markForCheck(); + + if (this.readingListMode && info.seriesFormat !== MangaFormat.EPUB) { + // Redirect to the manga reader. + const params = this.readerService.getQueryParamsObject(this.incognitoMode, this.readingListMode, this.readingListId); + this.router.navigate(this.readerService.getNavigationArray(info.libraryId, info.seriesId, this.chapterId, info.seriesFormat), {queryParams: params}); + return; + } + }); + this.bookTitle = info.bookTitle; this.cdRef.markForCheck(); @@ -672,10 +679,6 @@ 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; - }); - this.updateImageSizes(); if (this.pageNum >= this.maxPages) { diff --git a/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts b/UI/Web/src/app/cards/card-detail-drawer/card-detail-drawer.component.ts new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts index 64a933552..2a65f1b01 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts +++ b/UI/Web/src/app/reading-list/_components/reading-list-detail/reading-list-detail.component.ts @@ -282,7 +282,8 @@ export class ReadingListDetailComponent implements OnInit { readChapter(item: ReadingListItem) { if (!this.readingList) return; const params = this.readerService.getQueryParamsObject(false, true, this.readingList.id); - this.router.navigate(this.readerService.getNavigationArray(item.libraryId, item.seriesId, item.chapterId, item.seriesFormat), {queryParams: params}); + this.router.navigate(this.readerService.getNavigationArray(item.libraryId, item.seriesId, item.chapterId, + item.seriesFormat), {queryParams: params}); } async handleReadingListActionCallback(action: ActionItem, readingList: ReadingList) { @@ -366,7 +367,8 @@ export class ReadingListDetailComponent implements OnInit { if (!this.readingList) return; const firstItem = this.items[0]; this.router.navigate( - this.readerService.getNavigationArray(firstItem.libraryId, firstItem.seriesId, firstItem.chapterId, firstItem.seriesFormat), + this.readerService.getNavigationArray(firstItem.libraryId, firstItem.seriesId, firstItem.chapterId, + firstItem.seriesFormat), {queryParams: {readingListId: this.readingList.id, incognitoMode: incognitoMode}}); } @@ -383,7 +385,8 @@ export class ReadingListDetailComponent implements OnInit { } this.router.navigate( - this.readerService.getNavigationArray(currentlyReadingChapter.libraryId, currentlyReadingChapter.seriesId, currentlyReadingChapter.chapterId, currentlyReadingChapter.seriesFormat), + this.readerService.getNavigationArray(currentlyReadingChapter.libraryId, currentlyReadingChapter.seriesId, + currentlyReadingChapter.chapterId, currentlyReadingChapter.seriesFormat), {queryParams: {readingListId: this.readingList.id, incognitoMode: incognitoMode}}); } diff --git a/UI/Web/src/app/shared/_services/utility.service.ts b/UI/Web/src/app/shared/_services/utility.service.ts index 94c5cf696..a2edc3c5a 100644 --- a/UI/Web/src/app/shared/_services/utility.service.ts +++ b/UI/Web/src/app/shared/_services/utility.service.ts @@ -1,12 +1,12 @@ -import { HttpParams } from '@angular/common/http'; -import { Injectable } from '@angular/core'; -import { Chapter } from 'src/app/_models/chapter'; -import { LibraryType } from 'src/app/_models/library/library'; -import { MangaFormat } from 'src/app/_models/manga-format'; -import { PaginatedResult } from 'src/app/_models/pagination'; -import { Series } from 'src/app/_models/series'; -import { Volume } from 'src/app/_models/volume'; -import {translate, TranslocoService} from "@jsverse/transloco"; +import {HttpParams} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {Chapter} from 'src/app/_models/chapter'; +import {LibraryType} from 'src/app/_models/library/library'; +import {MangaFormat} from 'src/app/_models/manga-format'; +import {PaginatedResult} from 'src/app/_models/pagination'; +import {Series} from 'src/app/_models/series'; +import {Volume} from 'src/app/_models/volume'; +import {translate} from "@jsverse/transloco"; import {debounceTime, ReplaySubject, shareReplay} from "rxjs"; export enum KEY_CODES { @@ -73,6 +73,7 @@ export class UtilityService { return translate('common.book-num' + extra) + (includeSpace ? ' ' : ''); case LibraryType.Comic: case LibraryType.ComicVine: + case LibraryType.Magazine: if (includeHash) { return translate('common.issue-hash-num'); } diff --git a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts index 9ac7df15c..4c68d6d3e 100644 --- a/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts +++ b/UI/Web/src/app/sidenav/_components/side-nav/side-nav.component.ts @@ -212,6 +212,8 @@ export class SideNavComponent implements OnInit { return 'fa-book-open'; case LibraryType.Images: return 'fa-images'; + case LibraryType.Magazine: + return 'fa-book-open'; // TODO: Find an icon for this } } diff --git a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts index d8a0ff752..9785ddbc4 100644 --- a/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts +++ b/UI/Web/src/app/sidenav/_modals/library-settings-modal/library-settings-modal.component.ts @@ -214,6 +214,12 @@ export class LibrarySettingsModalComponent implements OnInit { this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(false); this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(false); break; + case LibraryType.Magazine: + this.libraryForm.get(FileTypeGroup.Archive + '')?.setValue(true); + this.libraryForm.get(FileTypeGroup.Images + '')?.setValue(false); + this.libraryForm.get(FileTypeGroup.Pdf + '')?.setValue(true); + this.libraryForm.get(FileTypeGroup.Epub + '')?.setValue(false); + break; } this.libraryForm.get('allowScrobbling')?.setValue(this.IsKavitaPlusEligible); diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index e460e3ffa..17319c120 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -583,7 +583,8 @@ "manga": "Manga", "comicVine": "Comic", "image": "Image", - "lightNovel": "Light Novel" + "lightNovel": "Light Novel", + "magazine": "Magazine" }, "age-rating-pipe": {