Compare commits
9 commits
develop
...
feature/ma
Author | SHA1 | Date | |
---|---|---|---|
![]() |
29167f281e | ||
![]() |
bad5c9dcd6 | ||
![]() |
08a32a26bc | ||
![]() |
d12a79892f | ||
![]() |
b3f6a574cd | ||
![]() |
95e7ad0f5b | ||
![]() |
5a522b6d5b | ||
![]() |
a443be7523 | ||
![]() |
93df0def48 |
25 changed files with 470 additions and 39 deletions
43
API.Tests/Parsing/MagazineParserTests.cs
Normal file
43
API.Tests/Parsing/MagazineParserTests.cs
Normal file
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return pairs of all types
|
||||
/// </summary>
|
||||
/// <returns></returns>
|
||||
[HttpGet("types")]
|
||||
public async Task<ActionResult<IEnumerable<LibraryTypeDto>>> GetLibraryTypes()
|
||||
{
|
||||
return Ok(await _unitOfWork.LibraryRepository.GetLibraryTypesAsync(User.GetUserId()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Returns various information about all bookmark files for a Series. Side effect: This will cache the bookmark images for reading.
|
||||
/// </summary>
|
||||
|
|
12
API/DTOs/LibraryTypeDto.cs
Normal file
12
API/DTOs/LibraryTypeDto.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
using API.Entities.Enums;
|
||||
|
||||
namespace API.DTOs;
|
||||
|
||||
/// <summary>
|
||||
/// Simple pairing of LibraryId and LibraryType
|
||||
/// </summary>
|
||||
public sealed record LibraryTypeDto
|
||||
{
|
||||
public int LibraryId { get; set; }
|
||||
public LibraryType LibraryType { get; set; }
|
||||
}
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
@ -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; }
|
||||
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -58,6 +58,7 @@ public interface ILibraryRepository
|
|||
Task<bool> GetAllowsScrobblingBySeriesId(int seriesId);
|
||||
|
||||
Task<IDictionary<int, LibraryType>> GetLibraryTypesBySeriesIdsAsync(IList<int> seriesIds);
|
||||
Task<IEnumerable<LibraryTypeDto>> 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<IEnumerable<LibraryTypeDto>> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,4 +34,9 @@ public enum LibraryType
|
|||
/// </summary>
|
||||
[Description("Comic")]
|
||||
ComicVine = 5,
|
||||
/// <summary>
|
||||
/// Uses Magazine regex and is restricted to PDF and Archive by default
|
||||
/// </summary>
|
||||
[Description("Magazine")]
|
||||
Magazine = 6
|
||||
}
|
||||
|
|
|
@ -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<ReadingItemService> 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));
|
||||
|
|
|
@ -33,7 +33,6 @@ public interface ISeriesService
|
|||
Task<bool> UpdateRelatedSeries(UpdateRelatedSeriesDto dto);
|
||||
Task<RelatedSeriesDto> GetRelatedSeries(int userId, int seriesId);
|
||||
Task<string> FormatChapterTitle(int userId, ChapterDto chapter, LibraryType libraryType, bool withHash = true);
|
||||
Task<string> FormatChapterTitle(int userId, Chapter chapter, LibraryType libraryType, bool withHash = true);
|
||||
Task<string> FormatChapterTitle(int userId, bool isSpecial, LibraryType libraryType, string chapterRange, string? chapterTitle,
|
||||
bool withHash);
|
||||
Task<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false);
|
||||
|
@ -633,7 +632,7 @@ public class SeriesService : ISeriesService
|
|||
|
||||
public async Task<string> 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<string> 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<string> FormatChapterName(int userId, LibraryType libraryType, bool withHash = false)
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
86
API/Services/Tasks/Scanner/Parser/MagazineParser.cs
Normal file
86
API/Services/Tasks/Scanner/Parser/MagazineParser.cs
Normal file
|
@ -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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only applicable for PDF Files and Magazine library type
|
||||
/// </summary>
|
||||
/// <param name="filePath"></param>
|
||||
/// <param name="type"></param>
|
||||
/// <returns></returns>
|
||||
public override bool IsApplicable(string filePath, LibraryType type)
|
||||
{
|
||||
return type == LibraryType.Magazine && Parser.IsPdf(filePath);
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
|||
/// </summary>
|
||||
private const string TagsInBrackets = $@"\[(?!\s){BalancedBracket}(?<!\s)\]";
|
||||
|
||||
[GeneratedRegex(@"^\d+$")]
|
||||
private static partial Regex IsNumberRegex();
|
||||
|
||||
/// <summary>
|
||||
/// 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<string> GeoCodes = new(CreateCountryCodes());
|
||||
private static readonly Dictionary<string, int> MonthMappings = CreateMonthMappings();
|
||||
private static readonly Regex[] MagazineSeriesRegex =
|
||||
[
|
||||
// 3D World - 2018 UK, 3D World - 022014
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*\d{4,6}.*",
|
||||
MatchOptions, RegexTimeout),
|
||||
// AIR International - April 2018 UK
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*.*",
|
||||
MatchOptions, RegexTimeout),
|
||||
// AIR International #1 // This breaks the way the code works
|
||||
// new Regex(
|
||||
// @"^(?<Series>.+?)(_|\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(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*\d{2}?(?<Volume>\d{4}).*",
|
||||
MatchOptions, RegexTimeout),
|
||||
// 3D World - Sept 2018
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*\D+(?<Volume>\d{4}).*",
|
||||
MatchOptions, RegexTimeout),
|
||||
// 3D World - Sept 2018
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*\D+(?<Volume>\d{4}).*",
|
||||
MatchOptions, RegexTimeout),
|
||||
|
||||
};
|
||||
|
||||
private static readonly Regex[] MagazineChapterRegex = new[]
|
||||
{
|
||||
// 3D World - September 2023 #2
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*.*#(?<Chapter>\d+).*",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Computer Weekly - September 2023
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*(?<Chapter>January|February|March|April|May|June|July|August|September|October|November|December).*",
|
||||
MatchOptions, RegexTimeout),
|
||||
// Computer Weekly - Sept 2023
|
||||
new Regex(
|
||||
@"^(?<Series>.+?)(_|\s)*-(_|\s)*(?<Chapter>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<string, int> CreateMonthMappings()
|
||||
{
|
||||
Dictionary<string, int> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to parse a GeoCode (UK, US) out of a string
|
||||
/// </summary>
|
||||
/// <param name="value"></param>
|
||||
/// <returns></returns>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
// /// <summary>
|
||||
// /// Tries to parse a GTIN/ISBN out of a string
|
||||
// /// </summary>
|
||||
// /// <param name="value"></param>
|
||||
// /// <returns></returns>
|
||||
// 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)
|
||||
/// <summary>
|
||||
/// Extracts year from Series (Year)
|
||||
/// </summary>
|
||||
/// <param name="name"></param>
|
||||
/// <returns></returns>
|
||||
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)
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
@ -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<Array<{libraryId: number, libraryType: LibraryType}>>(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]);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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: 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}});
|
||||
}
|
||||
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -583,7 +583,8 @@
|
|||
"manga": "Manga",
|
||||
"comicVine": "Comic",
|
||||
"image": "Image",
|
||||
"lightNovel": "Light Novel"
|
||||
"lightNovel": "Light Novel",
|
||||
"magazine": "Magazine"
|
||||
},
|
||||
|
||||
"age-rating-pipe": {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue