Foundational Rework (#2745)

This commit is contained in:
Joe Milazzo 2024-02-26 14:56:39 -06:00 committed by GitHub
parent 42cd6e9b3a
commit 4fa21fe1ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
92 changed files with 13330 additions and 650 deletions

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
namespace API.Comparators;
@ -6,28 +7,28 @@ namespace API.Comparators;
#nullable enable
/// <summary>
/// Sorts chapters based on their Number. Uses natural ordering of doubles.
/// Sorts chapters based on their Number. Uses natural ordering of doubles. Specials always LAST.
/// </summary>
public class ChapterSortComparer : IComparer<double>
public class ChapterSortComparerDefaultLast : IComparer<float>
{
/// <summary>
/// Normal sort for 2 doubles. 0 always comes last
/// Normal sort for 2 doubles. DefaultChapterNumber always comes last
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public int Compare(double x, double y)
public int Compare(float x, float y)
{
if (x == Parser.DefaultChapterNumber && y == Parser.DefaultChapterNumber) return 0;
if (x.Is(Parser.DefaultChapterNumber) && y.Is(Parser.DefaultChapterNumber)) return 0;
// if x is 0, it comes second
if (x == Parser.DefaultChapterNumber) return 1;
if (x.Is(Parser.DefaultChapterNumber)) return 1;
// if y is 0, it comes second
if (y == Parser.DefaultChapterNumber) return -1;
if (y.Is(Parser.DefaultChapterNumber)) return -1;
return x.CompareTo(y);
}
public static readonly ChapterSortComparer Default = new ChapterSortComparer();
public static readonly ChapterSortComparerDefaultLast Default = new ChapterSortComparerDefaultLast();
}
/// <summary>
@ -37,33 +38,43 @@ public class ChapterSortComparer : IComparer<double>
/// This is represented by Chapter 0, Chapter 81.
/// </example>
/// </summary>
public class ChapterSortComparerZeroFirst : IComparer<double>
public class ChapterSortComparerDefaultFirst : IComparer<float>
{
public int Compare(double x, double y)
public int Compare(float x, float y)
{
if (x == Parser.DefaultChapterNumber && y == Parser.DefaultChapterNumber) return 0;
if (x.Is(Parser.DefaultChapterNumber) && y.Is(Parser.DefaultChapterNumber)) return 0;
// if x is 0, it comes first
if (x == Parser.DefaultChapterNumber) return -1;
if (x.Is(Parser.DefaultChapterNumber)) return -1;
// if y is 0, it comes first
if (y == Parser.DefaultChapterNumber) return 1;
if (y.Is(Parser.DefaultChapterNumber)) return 1;
return x.CompareTo(y);
}
public static readonly ChapterSortComparerZeroFirst Default = new ChapterSortComparerZeroFirst();
public static readonly ChapterSortComparerDefaultFirst Default = new ChapterSortComparerDefaultFirst();
}
public class SortComparerZeroLast : IComparer<double>
/// <summary>
/// Sorts chapters based on their Number. Uses natural ordering of doubles. Specials always LAST.
/// </summary>
public class ChapterSortComparerSpecialsLast : IComparer<float>
{
public int Compare(double x, double y)
/// <summary>
/// Normal sort for 2 doubles. DefaultSpecialNumber always comes last
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <returns></returns>
public int Compare(float x, float y)
{
if (x == Parser.DefaultChapterNumber && y == Parser.DefaultChapterNumber) return 0;
// if x is 0, it comes last
if (x == Parser.DefaultChapterNumber) return 1;
// if y is 0, it comes last
if (y == Parser.DefaultChapterNumber) return -1;
if (x.Is(Parser.SpecialVolumeNumber) && y.Is(Parser.SpecialVolumeNumber)) return 0;
// if x is 0, it comes second
if (x.Is(Parser.SpecialVolumeNumber)) return 1;
// if y is 0, it comes second
if (y.Is(Parser.SpecialVolumeNumber)) return -1;
return x.CompareTo(y);
}
public static readonly SortComparerZeroLast Default = new SortComparerZeroLast();
public static readonly ChapterSortComparerSpecialsLast Default = new ChapterSortComparerSpecialsLast();
}

View file

@ -140,7 +140,7 @@ public class DownloadController : BaseApiController
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId);
try
{
return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series!.Name} - Chapter {chapter.Number}.zip");
return await DownloadFiles(files, $"download_{User.GetUsername()}_c{chapterId}", $"{series!.Name} - Chapter {chapter.GetNumberTitle()}.zip");
}
catch (KavitaException ex)
{

View file

@ -70,7 +70,7 @@ public class OpdsController : BaseApiController
};
private readonly FilterV2Dto _filterV2Dto = new FilterV2Dto();
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
private const int PageSize = 20;
public OpdsController(IUnitOfWork unitOfWork, IDownloadService downloadService,
@ -857,8 +857,8 @@ public class OpdsController : BaseApiController
var seriesDetail = await _seriesService.GetSeriesDetail(seriesId, userId);
foreach (var volume in seriesDetail.Volumes)
{
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id)).OrderBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture),
_chapterSortComparer);
var chapters = (await _unitOfWork.ChapterRepository.GetChaptersAsync(volume.Id))
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
foreach (var chapterId in chapters.Select(c => c.Id))
{
@ -907,8 +907,8 @@ public class OpdsController : BaseApiController
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var chapters =
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId)).OrderBy(x => double.Parse(x.Number, CultureInfo.InvariantCulture),
_chapterSortComparer);
(await _unitOfWork.ChapterRepository.GetChaptersAsync(volumeId))
.OrderBy(x => x.MinNumber, _chapterSortComparerDefaultLast);
var feed = CreateFeed(series.Name + " - Volume " + volume!.Name + $" - {_seriesService.FormatChapterName(userId, libraryType)}s ",
$"{prefix}{apiKey}/series/{seriesId}/volume/{volumeId}", apiKey, prefix);
SetFeedId(feed, $"series-{series.Id}-volume-{volume.Id}-{_seriesService.FormatChapterName(userId, libraryType)}s");
@ -1101,18 +1101,18 @@ public class OpdsController : BaseApiController
var title = $"{series.Name}";
if (volume!.Chapters.Count == 1)
if (volume!.Chapters.Count == 1 && !volume.IsSpecial())
{
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
SeriesService.RenameVolumeName(volume.Chapters.First(), volume, libraryType, volumeLabel);
if (volume.Name != Services.Tasks.Scanner.Parser.Parser.DefaultChapter)
SeriesService.RenameVolumeName(volume, libraryType, volumeLabel);
if (!volume.IsLooseLeaf())
{
title += $" - {volume.Name}";
}
}
else if (!volume.IsLooseLeaf())
else if (!volume.IsLooseLeaf() && !volume.IsSpecial())
{
title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
title = $"{series.Name} - Volume {volume.Name} - {await _seriesService.FormatChapterTitle(userId, chapter, libraryType)}";
}
else
{

View file

@ -13,13 +13,17 @@ public class ChapterDto : IHasReadTimeEstimate
{
public int Id { get; init; }
/// <summary>
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
/// Range of chapters. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If special, will be special name.
/// </summary>
public string Range { get; init; } = default!;
/// <summary>
/// Smallest number of the Range.
/// </summary>
[Obsolete("Use MinNumber and MaxNumber instead")]
public string Number { get; init; } = default!;
public float MinNumber { get; init; }
public float MaxNumber { get; init; }
public float SortOrder { get; init; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>

View file

@ -14,5 +14,5 @@ public class ReadHistoryEvent
public required string SeriesName { get; set; } = default!;
public DateTime ReadDate { get; set; }
public int ChapterId { get; set; }
public required string ChapterNumber { get; set; } = default!;
public required float ChapterNumber { get; set; } = default!;
}

View file

@ -0,0 +1,12 @@
namespace API.DTOs;
/// <summary>
/// This is explicitly for Tachiyomi. Number field was removed in v0.8.0, but Tachiyomi needs it for the hacks.
/// </summary>
public class TachiyomiChapterDto : ChapterDto
{
/// <summary>
/// Smallest number of the Range.
/// </summary>
public string Number { get; init; } = default!;
}

View file

@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
using API.Entities;
using API.Entities.Interfaces;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
namespace API.DTOs;
@ -20,7 +21,7 @@ public class VolumeDto : IHasReadTimeEstimate
/// This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14
/// </summary>
[Obsolete("Use MinNumber")]
public float Number { get; set; }
public int Number { get; set; }
public int Pages { get; set; }
public int PagesRead { get; set; }
public DateTime LastModifiedUtc { get; set; }
@ -50,6 +51,15 @@ public class VolumeDto : IHasReadTimeEstimate
/// <returns></returns>
public bool IsLooseLeaf()
{
return Math.Abs(this.MinNumber - Parser.LooseLeafVolumeNumber) < 0.001f;
return MinNumber.Is(Parser.LooseLeafVolumeNumber);
}
/// <summary>
/// Does this volume hold only specials?
/// </summary>
/// <returns></returns>
public bool IsSpecial()
{
return MinNumber.Is(Parser.SpecialVolumeNumber);
}
}

View file

@ -156,10 +156,15 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
{
if (e.FromQuery || e.Entry.State != EntityState.Added || e.Entry.Entity is not IEntityDate entity) return;
entity.Created = DateTime.Now;
entity.LastModified = DateTime.Now;
entity.CreatedUtc = DateTime.UtcNow;
entity.LastModifiedUtc = DateTime.UtcNow;
// This allows for mocking
if (entity.Created == DateTime.MinValue)
{
entity.Created = DateTime.Now;
entity.CreatedUtc = DateTime.UtcNow;
}
}
private static void OnEntityStateChanged(object? sender, EntityStateChangedEventArgs e)

View file

@ -0,0 +1,140 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using API.Helpers.Builders;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
public class UserProgressCsvRecord
{
public bool IsSpecial { get; set; }
public int AppUserId { get; set; }
public int PagesRead { get; set; }
public string Range { get; set; }
public string Number { get; set; }
public float MinNumber { get; set; }
public int SeriesId { get; set; }
public int VolumeId { get; set; }
}
/// <summary>
/// v0.8.0 migration to move Specials into their own volume and retain user progress.
/// </summary>
public static class MigrateMixedSpecials
{
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "ManualMigrateMixedSpecials"))
{
return;
}
logger.LogCritical(
"Running ManualMigrateMixedSpecials migration - Please be patient, this may take some time. This is not an error");
// First, group all the progresses into different series
// Get each series and move the specials from old volume to the new Volume()
// Create a new progress event from existing and store the Id of existing progress event to delete it
// Save per series
var progress = await dataContext.AppUserProgresses
.Join(dataContext.Chapter, p => p.ChapterId, c => c.Id, (p, c) => new UserProgressCsvRecord
{
IsSpecial = c.IsSpecial,
AppUserId = p.AppUserId,
PagesRead = p.PagesRead,
Range = c.Range,
Number = c.Number,
MinNumber = c.MinNumber,
SeriesId = p.SeriesId,
VolumeId = p.VolumeId
})
.Where(d => d.IsSpecial || d.Number == "0")
.Join(dataContext.Volume, d => d.VolumeId, v => v.Id, (d, v) => new
{
ProgressRecord = d,
Volume = v
})
.Where(d => d.Volume.Name == "0")
.ToListAsync();
// First, group all the progresses into different series
logger.LogCritical("Migrating {Count} progress events to new Volume structure - This may take over 10 minutes depending on size of DB. Please wait", progress.Count);
var progressesGroupedBySeries = progress.GroupBy(p => p.ProgressRecord.SeriesId);
foreach (var seriesGroup in progressesGroupedBySeries)
{
// Get each series and move the specials from the old volume to the new Volume
var seriesId = seriesGroup.Key;
var specialsInSeries = seriesGroup
.Where(p => p.ProgressRecord.IsSpecial)
.ToList();
// Get distinct Volumes by Id. For each one, create it then create the progress events
var distinctVolumes = specialsInSeries.DistinctBy(d => d.Volume.Id);
foreach (var distinctVolume in distinctVolumes)
{
// Create a new volume for each series with the appropriate number (-100000)
var chapters = await dataContext.Chapter
.Where(c => c.VolumeId == distinctVolume.Volume.Id && c.IsSpecial).ToListAsync();
var newVolume = new VolumeBuilder(Parser.SpecialVolume)
.WithSeriesId(seriesId)
.WithChapters(chapters)
.Build();
dataContext.Volume.Add(newVolume);
await dataContext.SaveChangesAsync(); // Save changes to generate the newVolumeId
// Migrate the progress event to the new volume
distinctVolume.ProgressRecord.VolumeId = newVolume.Id;
logger.LogInformation("Moving {Count} chapters from Volume Id {OldVolumeId} to New Volume {NewVolumeId}",
chapters.Count, distinctVolume.Volume.Id, newVolume.Id);
// Move the special chapters from the old volume to the new Volume
var specialChapters = await dataContext.Chapter
.Where(c => c.VolumeId == distinctVolume.ProgressRecord.VolumeId && c.IsSpecial)
.ToListAsync();
foreach (var specialChapter in specialChapters)
{
// Update the VolumeId on the existing progress event
specialChapter.VolumeId = newVolume.Id;
}
await dataContext.SaveChangesAsync();
}
}
// Save changes after processing all series
if (dataContext.ChangeTracker.HasChanges())
{
await dataContext.SaveChangesAsync();
}
// Update all Volumes with Name as "0" -> Special
logger.LogCritical("Updating all Volumes with Name 0 to SpecialNumber");
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "ManualMigrateMixedSpecials",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running ManualMigrateMixedSpecials migration - Completed. This is not an error");
}
}

View file

@ -0,0 +1,89 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Introduced in v0.8.0, this migrates the existing Chapter and Volume 0 -> Parser defined, MangaFile.FileName
/// </summary>
public static class MigrateChapterFields
{
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateChapterFields"))
{
return;
}
logger.LogCritical(
"Running MigrateChapterFields migration - Please be patient, this may take some time. This is not an error");
// Update all volumes only have specials in them (rare)
var volumesWithJustSpecials = dataContext.Volume
.Include(v => v.Chapters)
.Where(v => v.Name == "0" && v.Chapters.All(c => c.IsSpecial))
.ToList();
logger.LogCritical(
"Running MigrateChapterFields migration - Updating {Count} volumes that only have specials in them", volumesWithJustSpecials.Count);
foreach (var volume in volumesWithJustSpecials)
{
volume.Name = $"{Parser.SpecialVolumeNumber}";
volume.MinNumber = Parser.SpecialVolumeNumber;
volume.MaxNumber = Parser.SpecialVolumeNumber;
}
// Update all volumes that only have loose leafs in them
var looseLeafVolumes = dataContext.Volume
.Include(v => v.Chapters)
.Where(v => v.Name == "0" && v.Chapters.All(c => !c.IsSpecial))
.ToList();
logger.LogCritical(
"Running MigrateChapterFields migration - Updating {Count} volumes that only have loose leaf chapters in them", looseLeafVolumes.Count);
foreach (var volume in looseLeafVolumes)
{
volume.Name = $"{Parser.DefaultChapterNumber}";
volume.MinNumber = Parser.DefaultChapterNumber;
volume.MaxNumber = Parser.DefaultChapterNumber;
}
// Update all MangaFile
logger.LogCritical(
"Running MigrateChapterFields migration - Updating all MangaFiles");
foreach (var mangaFile in dataContext.MangaFile)
{
mangaFile.FileName = Path.GetFileNameWithoutExtension(mangaFile.FilePath);
}
var looseLeafChapters = await dataContext.Chapter.Where(c => c.Number == "0").ToListAsync();
logger.LogCritical(
"Running MigrateChapterFields migration - Updating {Count} loose leaf chapters", looseLeafChapters.Count);
foreach (var chapter in looseLeafChapters)
{
chapter.Number = Parser.DefaultChapter;
chapter.MinNumber = Parser.DefaultChapterNumber;
chapter.MaxNumber = Parser.DefaultChapterNumber;
}
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateChapterFields",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running MigrateChapterFields migration - Completed. This is not an error");
}
}

View file

@ -0,0 +1,50 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using API.Services.Tasks.Scanner.Parser;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Introduced in v0.8.0, this migrates the existing Chapter Range -> Chapter Min/Max Number
/// </summary>
public static class MigrateChapterNumber
{
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateChapterNumber"))
{
return;
}
logger.LogCritical(
"Running MigrateChapterNumber migration - Please be patient, this may take some time. This is not an error");
// Get all volumes
foreach (var chapter in dataContext.Chapter)
{
if (chapter.IsSpecial)
{
chapter.MinNumber = Parser.DefaultChapterNumber;
chapter.MaxNumber = Parser.DefaultChapterNumber;
continue;
}
chapter.MinNumber = Parser.MinNumberFromRange(chapter.Range);
chapter.MaxNumber = Parser.MaxNumberFromRange(chapter.Range);
}
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateChapterNumber",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running MigrateChapterNumber migration - Completed. This is not an error");
}
}

View file

@ -15,9 +15,8 @@ public static class MigrateLibrariesToHaveAllFileTypes
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
if (await dataContext.Library.AnyAsync(l => l.LibraryFileTypes.Count == 0))
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateLibrariesToHaveAllFileTypes"))
{
logger.LogCritical("Running MigrateLibrariesToHaveAllFileTypes migration - Completed. This is not an error");
return;
}

View file

@ -16,8 +16,6 @@ public static class MigrateManualHistory
{
if (await dataContext.ManualMigrationHistory.AnyAsync())
{
logger.LogCritical(
"Running MigrateManualHistory migration - Completed. This is not an error");
return;
}

View file

@ -4,6 +4,7 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.DTOs.Filtering.v2;
using API.Helpers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
@ -21,8 +22,12 @@ public static class MigrateSmartFilterEncoding
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateSmartFilterEncoding migration - Please be patient, this may take some time. This is not an error");
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateSmartFilterEncoding"))
{
return;
}
logger.LogCritical("Running MigrateSmartFilterEncoding migration - Please be patient, this may take some time. This is not an error");
var smartFilters = dataContext.AppUserSmartFilter.ToList();
foreach (var filter in smartFilters)

View file

@ -14,6 +14,10 @@ public static class MigrateUserLibrarySideNavStream
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateUserLibrarySideNavStream"))
{
return;
}
var usersWithLibraryStreams = await dataContext.AppUser
.Include(u => u.SideNavStreams)

View file

@ -0,0 +1,41 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
public static class MigrateVolumeLookupName
{
public static async Task Migrate(DataContext dataContext, IUnitOfWork unitOfWork, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateVolumeLookupName"))
{
return;
}
logger.LogCritical(
"Running MigrateVolumeLookupName migration - Please be patient, this may take some time. This is not an error");
// Update all volumes to have LookupName as after this migration, name isn't used for lookup
var volumes = dataContext.Volume.ToList();
foreach (var volume in volumes)
{
volume.LookupName = volume.Name;
}
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateVolumeLookupName",
ProductVersion = BuildInfo.Version.ToString(),
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running MigrateVolumeLookupName migration - Completed. This is not an error");
}
}

View file

@ -13,8 +13,13 @@ namespace API.Data.ManualMigrations;
/// </summary>
public static class MigrateVolumeNumber
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateVolumeNumber"))
{
return;
}
if (await dataContext.Volume.AnyAsync(v => v.MaxNumber > 0))
{
logger.LogCritical(

View file

@ -20,6 +20,11 @@ public static class MigrateWantToReadExport
{
try
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadExport"))
{
return;
}
var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv");
if (File.Exists(importFile))
{

View file

@ -6,6 +6,7 @@ using API.Data.Repositories;
using API.Entities;
using API.Services;
using CsvHelper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
@ -15,8 +16,14 @@ namespace API.Data.ManualMigrations;
/// </summary>
public static class MigrateWantToReadImport
{
public static async Task Migrate(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<Program> logger)
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, IDirectoryService directoryService, ILogger<Program> logger)
{
if (await dataContext.ManualMigrationHistory.AnyAsync(m => m.Name == "MigrateWantToReadImport"))
{
return;
}
var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv");
var outputFile = Path.Join(directoryService.ConfigDirectory, "imported-want-to-read-migration.csv");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class ChapterNumber : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<float>(
name: "MaxNumber",
table: "Chapter",
type: "REAL",
nullable: false,
defaultValue: 0f);
migrationBuilder.AddColumn<float>(
name: "MinNumber",
table: "Chapter",
type: "REAL",
nullable: false,
defaultValue: 0f);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "MaxNumber",
table: "Chapter");
migrationBuilder.DropColumn(
name: "MinNumber",
table: "Chapter");
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class MangaFileNameTemp : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FileName",
table: "MangaFile",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FileName",
table: "MangaFile");
}
}
}

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class VolumeLookupName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "LookupName",
table: "Volume",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LookupName",
table: "Volume");
}
}
}

View file

@ -679,9 +679,15 @@ namespace API.Data.Migrations
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("MaxNumber")
.HasColumnType("REAL");
b.Property<int>("MinHoursToRead")
.HasColumnType("INTEGER");
b.Property<float>("MinNumber")
.HasColumnType("REAL");
b.Property<string>("Number")
.HasColumnType("TEXT");
@ -697,6 +703,9 @@ namespace API.Data.Migrations
b.Property<string>("SeriesGroup")
.HasColumnType("TEXT");
b.Property<float>("SortOrder")
.HasColumnType("REAL");
b.Property<string>("StoryArc")
.HasColumnType("TEXT");
@ -973,6 +982,9 @@ namespace API.Data.Migrations
b.Property<string>("Extension")
.HasColumnType("TEXT");
b.Property<string>("FileName")
.HasColumnType("TEXT");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
@ -1839,6 +1851,9 @@ namespace API.Data.Migrations
b.Property<DateTime>("LastModifiedUtc")
.HasColumnType("TEXT");
b.Property<string>("LookupName")
.HasColumnType("TEXT");
b.Property<int>("MaxHoursToRead")
.HasColumnType("INTEGER");

View file

@ -167,9 +167,10 @@ public class AppUserProgressRepository : IAppUserProgressRepository
(appUserProgresses, chapter) => new {appUserProgresses, chapter})
.Where(p => p.appUserProgresses.SeriesId == seriesId && p.appUserProgresses.AppUserId == userId &&
p.appUserProgresses.PagesRead >= p.chapter.Pages)
.Select(p => p.chapter.Range)
.Where(p => p.chapter.MaxNumber != Parser.SpecialVolumeNumber)
.Select(p => p.chapter.MaxNumber)
.ToListAsync();
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Where(d => d != null).Max(d => (int) Math.Floor(Parser.MaxNumberFromRange(d)));
return list.Count == 0 ? 0 : (int) list.DefaultIfEmpty().Max(d => d);
}
public async Task<float> GetHighestFullyReadVolumeForSeries(int seriesId, int userId)
@ -179,6 +180,7 @@ public class AppUserProgressRepository : IAppUserProgressRepository
(appUserProgresses, chapter) => new {appUserProgresses, chapter})
.Where(p => p.appUserProgresses.SeriesId == seriesId && p.appUserProgresses.AppUserId == userId &&
p.appUserProgresses.PagesRead >= p.chapter.Pages)
.Where(p => p.chapter.MaxNumber != Parser.SpecialVolumeNumber)
.Select(p => p.chapter.Volume.MaxNumber)
.ToListAsync();
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max();

View file

@ -78,7 +78,7 @@ public class ChapterRepository : IChapterRepository
.Where(c => c.Id == chapterId)
.Join(_context.Volume, c => c.VolumeId, v => v.Id, (chapter, volume) => new
{
ChapterNumber = chapter.Range,
ChapterNumber = chapter.MinNumber,
VolumeNumber = volume.Name,
VolumeId = volume.Id,
chapter.IsSpecial,
@ -102,8 +102,8 @@ public class ChapterRepository : IChapterRepository
})
.Select(data => new ChapterInfoDto()
{
ChapterNumber = data.ChapterNumber,
VolumeNumber = data.VolumeNumber + string.Empty,
ChapterNumber = data.ChapterNumber + string.Empty, // TODO: Fix this
VolumeNumber = data.VolumeNumber + string.Empty, // TODO: Fix this
VolumeId = data.VolumeId,
IsSpecial = data.IsSpecial,
SeriesId = data.SeriesId,
@ -175,6 +175,7 @@ public class ChapterRepository : IChapterRepository
{
return await _context.Chapter
.Includes(includes)
.OrderBy(c => c.SortOrder)
.FirstOrDefaultAsync(c => c.Id == chapterId);
}
@ -187,6 +188,7 @@ public class ChapterRepository : IChapterRepository
{
return await _context.Chapter
.Where(c => c.VolumeId == volumeId)
.OrderBy(c => c.SortOrder)
.ToListAsync();
}
@ -267,10 +269,16 @@ public class ChapterRepository : IChapterRepository
return chapter;
}
/// <summary>
/// Includes Volumes
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
public IEnumerable<Chapter> GetChaptersForSeries(int seriesId)
{
return _context.Chapter
.Where(c => c.Volume.SeriesId == seriesId)
.OrderBy(c => c.SortOrder)
.Include(c => c.Volume)
.AsEnumerable();
}

View file

@ -1891,8 +1891,8 @@ public class SeriesRepository : ISeriesRepository
VolumeId = c.VolumeId,
ChapterId = c.Id,
Format = c.Volume.Series.Format,
ChapterNumber = c.Number,
ChapterRange = c.Range,
ChapterNumber = c.MinNumber + string.Empty, // TODO: Refactor this
ChapterRange = c.Range, // TODO: Refactor this
IsSpecial = c.IsSpecial,
VolumeNumber = c.Volume.MinNumber,
ChapterTitle = c.Title,

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading.Tasks;
@ -6,6 +7,7 @@ using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services;
using AutoMapper;
using AutoMapper.QueryableExtensions;
@ -14,6 +16,15 @@ using Microsoft.EntityFrameworkCore;
namespace API.Data.Repositories;
[Flags]
public enum VolumeIncludes
{
None = 1,
Chapters = 2,
People = 4,
Tags = 8,
}
public interface IVolumeRepository
{
void Add(Volume volume);
@ -22,7 +33,7 @@ public interface IVolumeRepository
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);
Task<string?> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId);
Task<IList<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters);
Task<Volume?> GetVolumeAsync(int volumeId);
Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
@ -129,6 +140,7 @@ public class VolumeRepository : IVolumeRepository
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Files)
.AsSplitQuery()
.OrderBy(v => v.MinNumber)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.SingleOrDefaultAsync(vol => vol.Id == volumeId);
@ -177,22 +189,22 @@ public class VolumeRepository : IVolumeRepository
/// <param name="seriesId"></param>
/// <param name="userId"></param>
/// <returns></returns>
public async Task<IEnumerable<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId)
public async Task<IList<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters)
{
var volumes = await _context.Volume
.Where(vol => vol.SeriesId == seriesId)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.People)
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Tags)
.Includes(includes)
.OrderBy(volume => volume.MinNumber)
.ProjectTo<VolumeDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync();
await AddVolumeModifiers(userId, volumes);
SortSpecialChapters(volumes);
foreach (var volume in volumes)
{
volume.Chapters = volume.Chapters.OrderBy(c => c.SortOrder).ToList();
}
return volumes;
}
@ -213,15 +225,6 @@ public class VolumeRepository : IVolumeRepository
}
private static void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{
foreach (var v in volumes.WhereLooseLeaf())
{
v.Chapters = v.Chapters.OrderByNatural(x => x.Range).ToList();
}
}
private async Task AddVolumeModifiers(int userId, IReadOnlyCollection<VolumeDto> volumes)
{
var volIds = volumes.Select(s => s.Id);

View file

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using API.Entities.Enums;
using API.Entities.Interfaces;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
namespace API.Entities;
@ -10,14 +12,27 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
{
public int Id { get; set; }
/// <summary>
/// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2".
/// Range of numbers. Chapter 2-4 -> "2-4". Chapter 2 -> "2". If the chapter is a special, will return the Special Name
/// </summary>
public required string Range { get; set; }
/// <summary>
/// Smallest number of the Range. Can be a partial like Chapter 4.5
/// </summary>
[Obsolete("Use MinNumber and MaxNumber instead")]
public required string Number { get; set; }
/// <summary>
/// Minimum Chapter Number.
/// </summary>
public float MinNumber { get; set; }
/// <summary>
/// Maximum Chapter Number
/// </summary>
public float MaxNumber { get; set; }
/// <summary>
/// The sorting order of the Chapter. Inherits from MinNumber, but can be overridden.
/// </summary>
public float SortOrder { get; set; }
/// <summary>
/// The files that represent this Chapter
/// </summary>
public ICollection<MangaFile> Files { get; set; } = null!;
@ -44,6 +59,7 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
/// Used for books/specials to display custom title. For non-specials/books, will be set to <see cref="Range"/>
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Age Rating for the issue/chapter
/// </summary>
@ -130,10 +146,42 @@ public class Chapter : IEntityDate, IHasReadTimeEstimate
if (IsSpecial)
{
Number = Parser.DefaultChapter;
MinNumber = Parser.DefaultChapterNumber;
MaxNumber = Parser.DefaultChapterNumber;
}
Title = (IsSpecial && info.Format == MangaFormat.Epub)
? info.Title
: Range;
: Path.GetFileNameWithoutExtension(Range);
}
/// <summary>
/// Returns the Chapter Number. If the chapter is a range, returns that, formatted.
/// </summary>
/// <returns></returns>
public string GetNumberTitle()
{
if (MinNumber.Is(MaxNumber))
{
if (MinNumber.Is(Parser.DefaultChapterNumber) && IsSpecial)
{
return Title;
}
else
{
return $"{MinNumber}";
}
}
return $"{MinNumber}-{MaxNumber}";
}
/// <summary>
/// Is the Chapter representing a single Volume (volume 1.cbz). If so, Min/Max will be Default and will not be special
/// </summary>
/// <returns></returns>
public bool IsSingleVolumeChapter()
{
return MinNumber.Is(Parser.DefaultChapterNumber) && !IsSpecial;
}
}

View file

@ -13,6 +13,10 @@ public class MangaFile : IEntityDate
{
public int Id { get; set; }
/// <summary>
/// The filename without extension
/// </summary>
public string FileName { get; set; }
/// <summary>
/// Absolute path to the archive file
/// </summary>
public required string FilePath { get; set; }

View file

@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using API.Entities.Interfaces;
using API.Extensions;
using API.Services.Tasks.Scanner.Parser;
namespace API.Entities;
@ -13,6 +15,10 @@ public class Volume : IEntityDate, IHasReadTimeEstimate
/// <remarks>For Books with Series_index, this will map to the Series Index.</remarks>
public required string Name { get; set; }
/// <summary>
/// This is just the original Parsed volume number for lookups
/// </summary>
public string LookupName { get; set; }
/// <summary>
/// The minimum number in the Name field in Int form
/// </summary>
/// <remarks>Removed in v0.7.13.8, this was an int and we need the ability to have 0.5 volumes render on the UI</remarks>
@ -55,4 +61,17 @@ public class Volume : IEntityDate, IHasReadTimeEstimate
public Series Series { get; set; } = null!;
public int SeriesId { get; set; }
/// <summary>
/// Returns the Chapter Number. If the chapter is a range, returns that, formatted.
/// </summary>
/// <returns></returns>
public string GetNumberTitle()
{
if (MinNumber.Is(MaxNumber))
{
return $"{MinNumber}";
}
return $"{MinNumber}-{MaxNumber}";
}
}

View file

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using API.Entities;
using API.Helpers;
@ -30,7 +31,7 @@ public static class ChapterListExtensions
{
var specialTreatment = info.IsSpecialInfo();
return specialTreatment
? chapters.FirstOrDefault(c => c.Range == info.Filename || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath)))
? chapters.FirstOrDefault(c => c.Range == Path.GetFileNameWithoutExtension(info.Filename) || (c.Files.Select(f => f.FilePath).Contains(info.FullFilePath)))
: chapters.FirstOrDefault(c => c.Range == info.Chapters);
}

View file

@ -0,0 +1,26 @@
using System;
namespace API.Extensions;
public static class FloatExtensions
{
private const float Tolerance = 0.001f;
/// <summary>
/// Used to compare 2 floats together
/// </summary>
/// <param name="a"></param>
/// <param name="b"></param>
/// <returns></returns>
public static bool Is(this float a, float? b)
{
if (!b.HasValue) return false;
return Math.Abs((float) (a - b)) < Tolerance;
}
public static bool IsNot(this float a, float? b)
{
if (!b.HasValue) return false;
return Math.Abs((float) (a - b)) > Tolerance;
}
}

View file

@ -39,6 +39,31 @@ public static class IncludesExtensions
return queryable.AsSplitQuery();
}
public static IQueryable<Volume> Includes(this IQueryable<Volume> queryable,
VolumeIncludes includes)
{
if (includes.HasFlag(VolumeIncludes.Chapters))
{
queryable = queryable.Include(vol => vol.Chapters);
}
if (includes.HasFlag(VolumeIncludes.People))
{
queryable = queryable
.Include(vol => vol.Chapters)
.ThenInclude(c => c.People);
}
if (includes.HasFlag(VolumeIncludes.Tags))
{
queryable = queryable
.Include(vol => vol.Chapters)
.ThenInclude(c => c.Tags);
}
return queryable.AsSplitQuery();
}
public static IQueryable<Series> Includes(this IQueryable<Series> query,
SeriesIncludes includeFlags)
{

View file

@ -19,13 +19,13 @@ public static class SeriesExtensions
public static string? GetCoverImage(this Series series)
{
var volumes = (series.Volumes ?? [])
.OrderBy(v => v.MinNumber, ChapterSortComparer.Default)
.OrderBy(v => v.MinNumber, ChapterSortComparerDefaultLast.Default)
.ToList();
var firstVolume = volumes.GetCoverImage(series.Format);
if (firstVolume == null) return null;
var chapters = firstVolume.Chapters
.OrderBy(c => c.Number.AsDouble(), ChapterSortComparerZeroFirst.Default)
.OrderBy(c => c.SortOrder, ChapterSortComparerDefaultLast.Default)
.ToList();
if (chapters.Count > 1 && chapters.Exists(c => c.IsSpecial))
@ -41,25 +41,36 @@ public static class SeriesExtensions
// If we have loose leaf chapters
// if loose leaf chapters AND volumes, just return first volume
if (volumes.Count >= 1 && $"{volumes[0].MinNumber}" != Parser.LooseLeafVolume)
if (volumes.Count >= 1 && volumes[0].MinNumber.IsNot(Parser.LooseLeafVolumeNumber))
{
var looseLeafChapters = volumes.Where(v => $"{v.MinNumber}" == Parser.LooseLeafVolume)
.SelectMany(c => c.Chapters.Where(c => !c.IsSpecial))
.OrderBy(c => c.Number.AsDouble(), ChapterSortComparerZeroFirst.Default)
var looseLeafChapters = volumes.Where(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber))
.SelectMany(c => c.Chapters.Where(c2 => !c2.IsSpecial))
.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultFirst.Default)
.ToList();
if (looseLeafChapters.Count > 0 && (1.0f * volumes[0].MinNumber) > looseLeafChapters[0].Number.AsFloat())
if (looseLeafChapters.Count > 0 && volumes[0].MinNumber > looseLeafChapters[0].MinNumber)
{
return looseLeafChapters[0].CoverImage;
}
return firstVolume.CoverImage;
}
var firstLooseLeafChapter = volumes
.Where(v => $"{v.MinNumber}" == Parser.LooseLeafVolume)
.SelectMany(v => v.Chapters)
.OrderBy(c => c.Number.AsDouble(), ChapterSortComparerZeroFirst.Default)
.FirstOrDefault(c => !c.IsSpecial);
var chpts = volumes
.First(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber))
.Chapters
//.Where(v => v.MinNumber.Is(Parser.LooseLeafVolumeNumber))
//.SelectMany(v => v.Chapters)
return firstLooseLeafChapter?.CoverImage ?? firstVolume.CoverImage;
.Where(c => !c.IsSpecial)
.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default)
.ToList();
var exactlyChapter1 = chpts.FirstOrDefault(c => c.MinNumber.Is(1f));
if (exactlyChapter1 != null)
{
return exactlyChapter1.CoverImage;
}
return chpts.FirstOrDefault()?.CoverImage ?? firstVolume.CoverImage;
}
}

View file

@ -3,6 +3,7 @@ using System.Collections;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using API.Comparators;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
@ -24,7 +25,7 @@ public static class VolumeListExtensions
{
if (volumes == null) throw new ArgumentException("Volumes cannot be null");
if (seriesFormat == MangaFormat.Epub || seriesFormat == MangaFormat.Pdf)
if (seriesFormat is MangaFormat.Epub or MangaFormat.Pdf)
{
return volumes.MinBy(x => x.MinNumber);
}
@ -45,7 +46,7 @@ public static class VolumeListExtensions
/// <returns></returns>
public static bool HasAnyNonLooseLeafVolumes(this IEnumerable<Volume> volumes)
{
return volumes.Any(x => Math.Abs(x.MinNumber - Parser.DefaultChapterNumber) > 0.001f);
return volumes.Any(v => v.MinNumber.IsNot(Parser.DefaultChapterNumber));
}
/// <summary>
@ -55,7 +56,8 @@ public static class VolumeListExtensions
/// <returns></returns>
public static Volume? FirstNonLooseLeafOrDefault(this IEnumerable<Volume> volumes)
{
return volumes.OrderBy(x => x.MinNumber).FirstOrDefault(v => Math.Abs(v.MinNumber - Parser.DefaultChapterNumber) >= 0.001f);
return volumes.OrderBy(x => x.MinNumber, ChapterSortComparerDefaultLast.Default)
.FirstOrDefault(v => v.MinNumber.IsNot(Parser.DefaultChapterNumber));
}
/// <summary>
@ -65,16 +67,16 @@ public static class VolumeListExtensions
/// <returns></returns>
public static Volume? GetLooseLeafVolumeOrDefault(this IEnumerable<Volume> volumes)
{
return volumes.FirstOrDefault(v => Math.Abs(v.MinNumber - Parser.DefaultChapterNumber) < 0.001f);
return volumes.FirstOrDefault(v => v.MinNumber.Is(Parser.DefaultChapterNumber));
}
public static IEnumerable<VolumeDto> WhereNotLooseLeaf(this IEnumerable<VolumeDto> volumes)
{
return volumes.Where(v => Math.Abs(v.MinNumber - Parser.DefaultChapterNumber) >= 0.001f);
return volumes.Where(v => v.MinNumber.Is(Parser.DefaultChapterNumber));
}
public static IEnumerable<VolumeDto> WhereLooseLeaf(this IEnumerable<VolumeDto> volumes)
{
return volumes.Where(v => Math.Abs(v.MinNumber - Parser.DefaultChapterNumber) < 0.001f);
return volumes.Where(v => v.MinNumber.Is(Parser.DefaultChapterNumber));
}
}

View file

@ -47,7 +47,7 @@ public class AutoMapperProfiles : Profile
.ForMember(dest => dest.Series, opt => opt.MapFrom(src => src.Series));
CreateMap<LibraryDto, Library>();
CreateMap<Volume, VolumeDto>()
.ForMember(dest => dest.Number, opt => opt.MapFrom(src => src.MinNumber));
.ForMember(dest => dest.Number, opt => opt.MapFrom(src => (int) src.MinNumber));
CreateMap<MangaFile, MangaFileDto>();
CreateMap<Chapter, ChapterDto>();
CreateMap<Series, SeriesDto>();
@ -200,6 +200,8 @@ public class AutoMapperProfiles : Profile
CreateMap<ReadingList, ReadingListDto>();
CreateMap<ReadingListItem, ReadingListItemDto>();
CreateMap<ScrobbleError, ScrobbleErrorDto>();
CreateMap<ChapterDto, TachiyomiChapterDto>();
CreateMap<Chapter, TachiyomiChapterDto>();
CreateMap<Series, SearchResultDto>()
.ForMember(dest => dest.SeriesId,

View file

@ -20,8 +20,12 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
Range = string.IsNullOrEmpty(range) ? number : range,
Title = string.IsNullOrEmpty(range) ? number : range,
Number = Parser.MinNumberFromRange(number).ToString(CultureInfo.InvariantCulture),
MinNumber = Parser.MinNumberFromRange(number),
MaxNumber = Parser.MaxNumberFromRange(number),
SortOrder = Parser.MinNumberFromRange(number),
Files = new List<MangaFile>(),
Pages = 1
Pages = 1,
CreatedUtc = DateTime.UtcNow
};
}
@ -30,11 +34,15 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
var specialTreatment = info.IsSpecialInfo();
var specialTitle = specialTreatment ? info.Filename : info.Chapters;
var builder = new ChapterBuilder(Parser.DefaultChapter);
// TODO: Come back here and remove this side effect
return builder.WithNumber(specialTreatment ? Parser.DefaultChapter : Parser.MinNumberFromRange(info.Chapters) + string.Empty)
.WithRange(specialTreatment ? info.Filename : info.Chapters)
.WithTitle((specialTreatment && info.Format == MangaFormat.Epub)
? info.Title
: specialTitle)
// NEW
//.WithTitle(string.IsNullOrEmpty(info.Filename) ? specialTitle : info.Filename)
.WithTitle(info.Filename)
.WithIsSpecial(specialTreatment);
}
@ -44,9 +52,18 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
return this;
}
public ChapterBuilder WithNumber(string number)
{
_chapter.Number = number;
_chapter.MinNumber = Parser.MinNumberFromRange(number);
_chapter.MaxNumber = Parser.MaxNumberFromRange(number);
return this;
}
public ChapterBuilder WithSortOrder(float order)
{
_chapter.SortOrder = order;
return this;
}
@ -65,6 +82,8 @@ public class ChapterBuilder : IEntityBuilder<Chapter>
private ChapterBuilder WithRange(string range)
{
_chapter.Range = range;
// TODO: HACK: Overriding range
_chapter.Range = _chapter.GetNumberTitle();
return this;
}

View file

@ -19,6 +19,7 @@ public class MangaFileBuilder : IEntityBuilder<MangaFile>
Pages = pages,
LastModified = File.GetLastWriteTime(filePath),
LastModifiedUtc = File.GetLastWriteTimeUtc(filePath),
FileName = Path.GetFileNameWithoutExtension(filePath)
};
}

View file

@ -26,7 +26,9 @@ public class SeriesBuilder : IEntityBuilder<Series>
SortName = name,
NormalizedName = name.ToNormalized(),
NormalizedLocalizedName = name.ToNormalized(),
Metadata = new SeriesMetadataBuilder().Build(),
Metadata = new SeriesMetadataBuilder()
.WithPublicationStatus(PublicationStatus.OnGoing)
.Build(),
Volumes = new List<Volume>(),
ExternalSeriesMetadata = new ExternalSeriesMetadata()
};
@ -90,4 +92,10 @@ public class SeriesBuilder : IEntityBuilder<Series>
_series.LibraryId = id;
return this;
}
public SeriesBuilder WithPublicationStatus(PublicationStatus status)
{
_series.Metadata.PublicationStatus = status;
return this;
}
}

View file

@ -15,6 +15,7 @@ public class VolumeBuilder : IEntityBuilder<Volume>
_volume = new Volume()
{
Name = volumeNumber,
LookupName = volumeNumber,
MinNumber = Services.Tasks.Scanner.Parser.Parser.MinNumberFromRange(volumeNumber),
MaxNumber = Services.Tasks.Scanner.Parser.Parser.MaxNumberFromRange(volumeNumber),
Chapters = new List<Chapter>()
@ -49,7 +50,7 @@ public class VolumeBuilder : IEntityBuilder<Volume>
return this;
}
public VolumeBuilder WithChapters(List<Chapter> chapters)
public VolumeBuilder WithChapters(IList<Chapter> chapters)
{
_volume.Chapters = chapters;
return this;

View file

@ -657,7 +657,7 @@ public class DirectoryService : IDirectoryService
/// <returns></returns>
public IList<string> ScanFiles(string folderPath, string fileTypes, GlobMatcher? matcher = null)
{
_logger.LogDebug("[ScanFiles] called on {Path}", folderPath);
_logger.LogTrace("[ScanFiles] called on {Path}", folderPath);
var files = new List<string>();
if (!Exists(folderPath)) return files;

View file

@ -197,7 +197,7 @@ public class MediaConversionService : IMediaConversionService
foreach (var volume in nonCustomOrConvertedVolumeCovers)
{
if (string.IsNullOrEmpty(volume.CoverImage)) continue;
volume.CoverImage = volume.Chapters.MinBy(x => x.Number.AsDouble(), ChapterSortComparerZeroFirst.Default)?.CoverImage;
volume.CoverImage = volume.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default)?.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
await _unitOfWork.CommitAsync();
}

View file

@ -108,7 +108,7 @@ public class MetadataService : IMetadataService
volume.Chapters ??= new List<Chapter>();
var firstChapter = volume.Chapters.MinBy(x => x.Number.AsDouble(), ChapterSortComparerZeroFirst.Default);
var firstChapter = volume.Chapters.MinBy(x => x.MinNumber, ChapterSortComparerDefaultFirst.Default);
if (firstChapter == null) return Task.FromResult(false);
volume.CoverImage = firstChapter.CoverImage;

View file

@ -11,7 +11,9 @@ using API.DTOs.Scrobbling;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Scrobble;
using API.Extensions;
using API.Helpers;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using Flurl.Http;
using Hangfire;
@ -330,6 +332,15 @@ public class ScrobblingService : IScrobblingService
await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadChapterForSeries(seriesId, userId),
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
};
// NOTE: Not sure how to handle scrobbling specials or handling sending loose leaf volumes
if (evt.VolumeNumber is Parser.SpecialVolumeNumber)
{
evt.VolumeNumber = 0;
}
if (evt.VolumeNumber is Parser.DefaultChapterNumber)
{
evt.VolumeNumber = 0;
}
_unitOfWork.ScrobbleRepository.Attach(evt);
await _unitOfWork.CommitAsync();
_logger.LogDebug("Added Scrobbling Read update on {SeriesName} with Userid {UserId} ", series.Name, userId);
@ -798,7 +809,7 @@ public class ScrobblingService : IScrobblingService
SeriesId = evt.SeriesId
});
evt.IsErrored = true;
evt.ErrorDetails = "Series cannot be matched for Scrobbling";
evt.ErrorDetails = UnknownSeriesErrorMessage;
evt.ProcessDateUtc = DateTime.UtcNow;
_unitOfWork.ScrobbleRepository.Update(evt);
await _unitOfWork.CommitAsync();

View file

@ -51,8 +51,9 @@ public class ReaderService : IReaderService
private readonly IImageService _imageService;
private readonly IDirectoryService _directoryService;
private readonly IScrobblingService _scrobblingService;
private readonly ChapterSortComparer _chapterSortComparer = ChapterSortComparer.Default;
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default;
private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default;
private readonly ChapterSortComparerSpecialsLast _chapterSortComparerSpecialsLast = ChapterSortComparerSpecialsLast.Default;
private const float MinWordsPerHour = 10260F;
private const float MaxWordsPerHour = 30000F;
@ -346,11 +347,23 @@ public class ReaderService : IReaderService
return page;
}
private int GetNextSpecialChapter(VolumeDto volume, ChapterDto currentChapter)
{
if (volume.IsSpecial())
{
// Handle specials by sorting on their Filename aka Range
return GetNextChapterId(volume.Chapters.OrderBy(x => x.SortOrder), currentChapter.SortOrder, dto => dto.SortOrder);
}
return -1;
}
/// <summary>
/// Tries to find the next logical Chapter
/// </summary>
/// <example>
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> SP 01 → SP 02
/// V1 → V2 → V3 chapter 0 → V3 chapter 10 → V0 chapter 1 -> V0 chapter 2 -> (Annual 1 -> Annual 2) -> (SP 01 → SP 02)
/// </example>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
@ -359,112 +372,88 @@ public class ReaderService : IReaderService
/// <returns>-1 if nothing can be found</returns>
public async Task<int> GetNextChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
{
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);
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
var currentVolume = volumes.FirstOrDefault(v => v.Id == volumeId);
if (currentVolume == null)
{
// Handle the case where the current volume is not found
return -1;
}
var currentChapter = currentVolume.Chapters.FirstOrDefault(c => c.Id == currentChapterId);
if (currentChapter == null)
{
// Handle the case where the current chapter is not found
return -1;
}
var currentVolumeIndex = volumes.IndexOf(currentVolume);
var chapterId = -1;
if (currentVolume.IsSpecial())
{
// Handle specials by sorting on their Range
chapterId = GetNextSpecialChapter(currentVolume, currentChapter);
return chapterId;
}
if (currentVolume.IsLooseLeaf())
{
// Handle specials by sorting on their Filename aka Range
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range), currentChapter.Range, dto => dto.Range);
// Handle loose-leaf chapters
chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.SortOrder),
currentChapter.SortOrder,
dto => dto.SortOrder);
if (chapterId > 0) return chapterId;
// Check specials next, as that is the order
if (currentVolumeIndex + 1 >= volumes.Count) return -1; // There are no special volumes, so there is nothing
var specialVolume = volumes[currentVolumeIndex + 1];
if (!specialVolume.IsSpecial()) return -1;
return specialVolume.Chapters.OrderByNatural(c => c.Range).FirstOrDefault()?.Id ?? -1;
}
var next = false;
foreach (var volume in volumes)
// Check within the current volume if the next chapter within it can be next
var chapters = currentVolume.Chapters.OrderBy(c => c.MinNumber).ToList();
var currentChapterIndex = chapters.IndexOf(currentChapter);
if (currentChapterIndex < chapters.Count - 1)
{
var volumeNumbersMatch = volume.Name == currentVolume.Name;
if (volumeNumbersMatch && volume.Chapters.Count > 1)
{
// Handle Chapters within current Volume
// In this case, i need 0 first because 0 represents a full volume file.
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Number.AsFloat(), _chapterSortComparer),
currentChapter.Range, dto => dto.Range);
if (chapterId > 0) return chapterId;
next = true;
continue;
}
if (volumeNumbersMatch)
{
next = true;
continue;
}
if (!next) continue;
// Handle Chapters within next Volume
// ! When selecting the chapter for the next volume, we need to make sure a c0 comes before a c1+
var chapters = volume.Chapters.OrderBy(x => x.Number.AsDouble(), _chapterSortComparer).ToList();
if (currentChapter.Number.Equals(Parser.DefaultChapter) && chapters[^1].Number.Equals(Parser.DefaultChapter))
{
// We need to handle an extra check if the current chapter is the last special, as we should return -1
if (currentChapter.IsSpecial) return -1;
return chapters.Last().Id;
}
var firstChapter = chapters.FirstOrDefault();
if (firstChapter == null) break;
var isSpecial = firstChapter.IsSpecial || currentChapter.IsSpecial;
if (isSpecial)
{
var chapterId = GetNextChapterId(volume.Chapters.OrderByNatural(x => x.Number),
currentChapter.Range, dto => dto.Range);
if (chapterId > 0) return chapterId;
} else if (firstChapter.Number.AsDouble() >= currentChapter.Number.AsDouble()) return firstChapter.Id;
// If we are the last chapter and next volume is there, we should try to use it (unless it's volume 0)
else if (firstChapter.Number.AsDouble() == Parser.DefaultChapterNumber) return firstChapter.Id;
// If on last volume AND there are no specials left, then let's return -1
var anySpecials = volumes.Where(v => $"{v.MinNumber}" == Parser.LooseLeafVolume)
.SelectMany(v => v.Chapters.Where(c => c.IsSpecial)).Any();
if (!currentVolume.IsLooseLeaf() && !anySpecials)
{
return -1;
}
return chapters[currentChapterIndex + 1].Id;
}
// Check within the current Volume
chapterId = GetNextChapterId(chapters, currentChapter.SortOrder, dto => dto.SortOrder);
if (chapterId > 0) return chapterId;
// If we are the last volume and we didn't find any next volume, loop back to volume 0 and give the first chapter
// This has an added problem that it will loop up to the beginning always
// Should I change this to Max number? volumes.LastOrDefault()?.Number -> volumes.Max(v => v.Number)
if (!currentVolume.IsLooseLeaf() && currentVolume.MinNumber == volumes.LastOrDefault()?.MinNumber && volumes.Count > 1)
// Now check the next volume
var nextVolumeIndex = currentVolumeIndex + 1;
if (nextVolumeIndex < volumes.Count)
{
var chapterVolume = volumes.FirstOrDefault();
if (chapterVolume == null || !chapterVolume.IsLooseLeaf()) return -1;
// Get the first chapter from the next volume
chapterId = volumes[nextVolumeIndex].Chapters.MinBy(c => c.MinNumber, _chapterSortComparerForInChapterSorting)?.Id ?? -1;
return chapterId;
}
// This is my attempt at fixing a bug where we loop around to the beginning, but I just can't seem to figure it out
// var orderedVolumes = volumes.OrderBy(v => v.Number, SortComparerZeroLast.Default).ToList();
// if (currentVolume.Number == orderedVolumes.FirstOrDefault().Number)
// {
// // We can move into loose leaf chapters
// //var firstLooseLeaf = volumes.LastOrDefault().Chapters.MinBy(x => x.Number.AsDouble(), _chapterSortComparer);
// var nextChapterId = GetNextChapterId(
// volumes.LastOrDefault().Chapters.OrderBy(x => x.Number.AsDouble(), _chapterSortComparer),
// "0", dto => dto.Range);
// // CHECK if we need a IsSpecial check
// if (nextChapterId > 0) return nextChapterId;
// }
var firstChapter = chapterVolume.Chapters.MinBy(x => x.Number.AsDouble(), _chapterSortComparer);
if (firstChapter == null) return -1;
return firstChapter.Id;
// We are the last volume, so we need to check loose leaf
if (currentVolumeIndex == volumes.Count - 1)
{
// Try to find the first loose-leaf chapter in this volume
var firstLooseLeafChapter = volumes.WhereLooseLeaf().FirstOrDefault()?.Chapters.MinBy(c => c.MinNumber, _chapterSortComparerForInChapterSorting);
if (firstLooseLeafChapter != null)
{
return firstLooseLeafChapter.Id;
}
}
return -1;
}
/// <summary>
/// Tries to find the prev logical Chapter
/// </summary>
/// <example>
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02
/// V1 ← V2 ← V3 chapter 0 ← V3 chapter 10 ← (V0 chapter 1 ← V0 chapter 2 ← SP 01 ← SP 02)
/// </example>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
@ -473,52 +462,76 @@ public class ReaderService : IReaderService
/// <returns>-1 if nothing can be found</returns>
public async Task<int> GetPrevChapterIdAsync(int seriesId, int volumeId, int currentChapterId, int userId)
{
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).Reverse().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);
if (currentVolume.IsLooseLeaf())
var chapterId = -1;
if (currentVolume.IsSpecial())
{
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderByNatural(x => x.Range).Reverse(), currentChapter.Range,
dto => dto.Range);
// Check within Specials, if not set the currentVolume to Loose Leaf
chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.SortOrder).Reverse(),
currentChapter.SortOrder,
dto => dto.SortOrder);
if (chapterId > 0) return chapterId;
currentVolume = volumes.FirstOrDefault(v => v.IsLooseLeaf());
}
var next = false;
foreach (var volume in volumes)
if (currentVolume != null && currentVolume.IsLooseLeaf())
{
if (volume.MinNumber == currentVolume.MinNumber)
{
var chapterId = GetNextChapterId(currentVolume.Chapters.OrderBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting).Reverse(),
currentChapter.Range, dto => dto.Range);
if (chapterId > 0) return chapterId;
next = true; // When the diff between volumes is more than 1, we need to explicitly tell that next volume is our use case
continue;
}
if (next)
{
if (currentVolume.MinNumber - 1 == Parser.LooseLeafVolumeNumber) break; // If we have walked all the way to chapter volume, then we should break so logic outside can work
var lastChapter = volume.Chapters.MaxBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting);
if (lastChapter == null) return -1;
return lastChapter.Id;
}
// If loose leaf, handle within the loose leaf. If not there, then set currentVolume to volumes.Last() where not LooseLeaf or Special
var currentVolumeChapters = currentVolume.Chapters.OrderBy(x => x.SortOrder).ToList();
chapterId = GetPrevChapterId(currentVolumeChapters,
currentChapter.SortOrder, dto => dto.SortOrder, c => c.Id);
if (chapterId > 0) return chapterId;
currentVolume = volumes.FindLast(v => !v.IsLooseLeaf() && !v.IsSpecial());
if (currentVolume != null) return currentVolume.Chapters.OrderBy(x => x.SortOrder).Last()?.Id ?? -1;
}
var lastVolume = volumes.MaxBy(v => v.MinNumber);
if (currentVolume.IsLooseLeaf() && currentVolume.MinNumber != lastVolume?.MinNumber && lastVolume?.Chapters.Count > 1)
// When we started as a special and there was no loose leafs, reset the currentVolume
if (currentVolume == null)
{
var lastChapter = lastVolume.Chapters.MaxBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting);
if (lastChapter == null) return -1;
return lastChapter.Id;
currentVolume = volumes.FirstOrDefault(v => !v.IsLooseLeaf() && !v.IsSpecial());
if (currentVolume == null) return -1;
return currentVolume.Chapters.OrderBy(x => x.SortOrder).Last()?.Id ?? -1;
}
// At this point, only need to check within the current Volume else move 1 level back
// Check current volume
chapterId = GetPrevChapterId(currentVolume.Chapters.OrderBy(x => x.SortOrder),
currentChapter.SortOrder, dto => dto.SortOrder, c => c.Id);
if (chapterId > 0) return chapterId;
var currentVolumeIndex = volumes.IndexOf(currentVolume);
if (currentVolumeIndex == 0) return -1;
currentVolume = volumes[currentVolumeIndex - 1];
if (currentVolume.IsLooseLeaf() || currentVolume.IsSpecial()) return -1;
chapterId = currentVolume.Chapters.OrderBy(x => x.SortOrder).Last().Id;
if (chapterId > 0) return chapterId;
return -1;
}
private static int GetPrevChapterId<T>(IEnumerable<T> source, float currentValue, Func<T, float> selector, Func<T, int> idSelector)
{
var sortedSource = source.OrderBy(selector).ToList();
var currentChapterIndex = sortedSource.FindIndex(x => selector(x).Is(currentValue));
if (currentChapterIndex > 0)
{
return idSelector(sortedSource[currentChapterIndex - 1]);
}
// There is no previous chapter
return -1;
}
/// <summary>
/// Finds the chapter to continue reading from. If a chapter has progress and not complete, return that. If not, progress in the
/// ordering (Volumes -> Loose Chapters -> Special) to find next chapter. If all are read, return first in order for series.
/// ordering (Volumes -> Loose Chapters -> Annuals -> Special) to find next chapter. If all are read, return first in order for series.
/// </summary>
/// <param name="seriesId"></param>
/// <param name="userId"></param>
@ -527,28 +540,42 @@ public class ReaderService : IReaderService
{
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
if (!await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId))
{
// I think i need a way to sort volumes last
var chapters = volumes.OrderBy(v => v.MinNumber, _chapterSortComparer).First().Chapters
.OrderBy(c => c.Number.AsFloat())
.ToList();
var anyUserProgress =
await _unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId);
// If there are specials, then return the first Non-special
if (chapters.Exists(c => c.IsSpecial))
{
var firstChapter = chapters.FirstOrDefault(c => !c.IsSpecial);
if (firstChapter == null)
{
// If there is no non-special chapter, then return first chapter
return chapters[0];
}
if (!anyUserProgress)
{
// I think i need a way to sort volumes last
volumes = volumes.OrderBy(v => v.MinNumber, _chapterSortComparerSpecialsLast).ToList();
return firstChapter;
}
// Else use normal logic
return chapters[0];
}
// Check if we have a non-loose leaf volume
var nonLooseLeafNonSpecialVolume = volumes.Find(v => !v.IsLooseLeaf() && !v.IsSpecial());
if (nonLooseLeafNonSpecialVolume != null)
{
return nonLooseLeafNonSpecialVolume.Chapters.MinBy(c => c.SortOrder);
}
// We only have a loose leaf or Special left
var chapters = volumes.First(v => v.IsLooseLeaf() || v.IsSpecial()).Chapters
.OrderBy(c => c.SortOrder)
.ToList();
// If there are specials, then return the first Non-special
if (chapters.Exists(c => c.IsSpecial))
{
var firstChapter = chapters.Find(c => !c.IsSpecial);
if (firstChapter == null)
{
// If there is no non-special chapter, then return first chapter
return chapters[0];
}
return firstChapter;
}
// Else use normal logic
return chapters[0];
}
// Loop through all chapters that are not in volume 0
var volumeChapters = volumes
@ -559,13 +586,13 @@ public class ReaderService : IReaderService
// NOTE: If volume 1 has chapter 1 and volume 2 is just chapter 0 due to being a full volume file, then this fails
// If there are any volumes that have progress, return those. If not, move on.
var currentlyReadingChapter = volumeChapters
.OrderBy(c => c.Number.AsDouble(), _chapterSortComparer)
.OrderBy(c => c.MinNumber, _chapterSortComparerDefaultLast)
.FirstOrDefault(chapter => chapter.PagesRead < chapter.Pages && chapter.PagesRead > 0);
if (currentlyReadingChapter != null) return currentlyReadingChapter;
// Order with volume 0 last so we prefer the natural order
return FindNextReadingChapter(volumes.OrderBy(v => v.MinNumber, SortComparerZeroLast.Default)
.SelectMany(v => v.Chapters.OrderBy(c => c.Number.AsDouble()))
return FindNextReadingChapter(volumes.OrderBy(v => v.MinNumber, _chapterSortComparerDefaultLast)
.SelectMany(v => v.Chapters.OrderBy(c => c.SortOrder))
.ToList());
}
@ -606,7 +633,7 @@ public class ReaderService : IReaderService
}
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, string currentChapterNumber, Func<ChapterDto, string> accessor)
private static int GetNextChapterId(IEnumerable<ChapterDto> chapters, float currentChapterNumber, Func<ChapterDto, float> accessor)
{
var next = false;
var chaptersList = chapters.ToList();
@ -636,8 +663,8 @@ public class ReaderService : IReaderService
foreach (var volume in volumes.OrderBy(v => v.MinNumber))
{
var chapters = volume.Chapters
.Where(c => !c.IsSpecial && Parser.MaxNumberFromRange(c.Range) <= chapterNumber)
.OrderBy(c => c.Number.AsFloat());
.Where(c => !c.IsSpecial && c.MaxNumber <= chapterNumber)
.OrderBy(c => c.MinNumber);
await MarkChaptersAsRead(user, volume.SeriesId, chapters.ToList());
}
}

View file

@ -57,7 +57,7 @@ public class ReadingListService : IReadingListService
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReadingListService> _logger;
private readonly IEventHub _eventHub;
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerZeroFirst.Default;
private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default;
private static readonly Regex JustNumbers = new Regex(@"^\d+$", RegexOptions.Compiled | RegexOptions.IgnoreCase,
Parser.RegexTimeout);
@ -391,8 +391,8 @@ public class ReadingListService : IReadingListService
var existingChapterExists = readingList.Items.Select(rli => rli.ChapterId).ToHashSet();
var chaptersForSeries = (await _unitOfWork.ChapterRepository.GetChaptersByIdsAsync(chapterIds, ChapterIncludes.Volumes))
.OrderBy(c => Parser.MinNumberFromRange(c.Volume.Name))
.ThenBy(x => x.Number.AsDouble(), _chapterSortComparerForInChapterSorting)
.OrderBy(c => c.Volume.MinNumber)
.ThenBy(x => x.MinNumber, _chapterSortComparerForInChapterSorting)
.ToList();
var index = readingList.Items.Count == 0 ? 0 : lastOrder + 1;
@ -647,9 +647,9 @@ public class ReadingListService : IReadingListService
// We need to handle chapter 0 or empty string when it's just a volume
var bookNumber = string.IsNullOrEmpty(book.Number)
? Parser.DefaultChapter
: book.Number;
var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Number == bookNumber);
? Parser.DefaultChapterNumber
: float.Parse(book.Number);
var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.MinNumber.Is(bookNumber));
if (chapter == null)
{
importSummary.Results.Add(new CblBookResult(book)

View file

@ -59,7 +59,7 @@ public class SeriesService : ISeriesService
{
ExpectedDate = null,
ChapterNumber = 0,
VolumeNumber = 0
VolumeNumber = Parser.LooseLeafVolumeNumber
};
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
@ -81,21 +81,21 @@ public class SeriesService : ISeriesService
public static Chapter? GetFirstChapterForMetadata(Series series)
{
var sortedVolumes = series.Volumes
.Where(v => float.TryParse(v.Name, CultureInfo.InvariantCulture, out var parsedValue) && parsedValue != Parser.LooseLeafVolumeNumber)
.OrderBy(v => float.TryParse(v.Name, CultureInfo.InvariantCulture, out var parsedValue) ? parsedValue : float.MaxValue);
.Where(v => v.MinNumber.IsNot(Parser.LooseLeafVolumeNumber))
.OrderBy(v => v.MinNumber);
var minVolumeNumber = sortedVolumes.MinBy(v => v.MinNumber);
var allChapters = series.Volumes
.SelectMany(v => v.Chapters.OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default))
.SelectMany(v => v.Chapters.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default))
.ToList();
var minChapter = allChapters
.FirstOrDefault();
if (minVolumeNumber != null && minChapter != null && float.TryParse(minChapter.Number, CultureInfo.InvariantCulture, out var chapNum) &&
(chapNum >= minVolumeNumber.MinNumber || chapNum == Parser.DefaultChapterNumber))
if (minVolumeNumber != null && minChapter != null &&
(minChapter.MinNumber >= minVolumeNumber.MinNumber || minChapter.MinNumber.Is(Parser.DefaultChapterNumber)))
{
return minVolumeNumber.Chapters.MinBy(c => c.Number.AsFloat(), ChapterSortComparer.Default);
return minVolumeNumber.Chapters.MinBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default);
}
return minChapter;
@ -481,74 +481,63 @@ public class SeriesService : ISeriesService
var libraryType = await _unitOfWork.LibraryRepository.GetLibraryTypeAsync(series.LibraryId);
var volumes = (await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId))
.OrderBy(v => Parser.MinNumberFromRange(v.Name))
.ToList();
var bookTreatment = libraryType is LibraryType.Book or LibraryType.LightNovel;
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
var volumes = await _unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId);
// For books, the Name of the Volume is remapped to the actual name of the book, rather than Volume number.
var processedVolumes = new List<VolumeDto>();
if (libraryType is LibraryType.Book or LibraryType.LightNovel)
foreach (var volume in volumes)
{
var volumeLabel = await _localizationService.Translate(userId, "volume-num", string.Empty);
foreach (var volume in volumes)
if (volume.IsLooseLeaf() || volume.IsSpecial())
{
continue;
}
volume.Chapters = volume.Chapters
.OrderBy(d => d.MinNumber, ChapterSortComparerDefaultLast.Default)
.ToList();
if (RenameVolumeName(volume, libraryType, volumeLabel) || (bookTreatment && !volume.IsSpecial()))
{
volume.Chapters = volume.Chapters
.OrderBy(d => d.Number.AsDouble(), ChapterSortComparer.Default)
.ToList();
var firstChapter = volume.Chapters.First();
// On Books, skip volumes that are specials, since these will be shown
if (firstChapter.IsSpecial) continue;
RenameVolumeName(firstChapter, volume, libraryType, volumeLabel);
processedVolumes.Add(volume);
}
}
else
{
processedVolumes = volumes.Where(v => v.MinNumber > 0).ToList();
processedVolumes.ForEach(v =>
{
v.Name = $"Volume {v.Name}";
v.Chapters = v.Chapters.OrderBy(d => d.Number.AsDouble(), ChapterSortComparer.Default).ToList();
});
}
var specials = new List<ChapterDto>();
var chapters = volumes.SelectMany(v => v.Chapters.Select(c =>
{
if (v.IsLooseLeaf()) return c;
c.VolumeTitle = v.Name;
return c;
}).OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default)).ToList();
// Why isn't this doing a check if chapter is not special as it wont get included
var chapters = volumes
.SelectMany(v => v.Chapters
.Select(c =>
{
if (v.IsLooseLeaf()) return c;
c.VolumeTitle = v.Name;
return c;
})
.OrderBy(c => c.SortOrder))
.ToList();
foreach (var chapter in chapters)
{
chapter.Title = await FormatChapterTitle(userId, chapter, libraryType);
if (!chapter.IsSpecial) continue;
if (!string.IsNullOrEmpty(chapter.TitleName)) chapter.Title = chapter.TitleName;
else chapter.Title = await FormatChapterTitle(userId, chapter, libraryType);
if (!chapter.IsSpecial) continue;
specials.Add(chapter);
}
// Don't show chapter 0 (aka single volume chapters) in the Chapters tab or books that are just single numbers (they show as volumes)
IEnumerable<ChapterDto> retChapters;
if (libraryType is LibraryType.Book or LibraryType.LightNovel)
{
retChapters = Array.Empty<ChapterDto>();
} else
{
retChapters = chapters
.Where(ShouldIncludeChapter);
}
IEnumerable<ChapterDto> retChapters = bookTreatment ? Array.Empty<ChapterDto>() : chapters.Where(ShouldIncludeChapter);
var storylineChapters = volumes
.WhereLooseLeaf()
.SelectMany(v => v.Chapters.Where(c => !c.IsSpecial))
.OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default)
.OrderBy(c => c.SortOrder)
.ToList();
// When there's chapters without a volume number revert to chapter sorting only as opposed to volume then chapter
if (storylineChapters.Any()) {
retChapters = retChapters.OrderBy(c => c.Number.AsFloat(), ChapterSortComparer.Default);
if (storylineChapters.Count > 0) {
retChapters = retChapters.OrderBy(c => c.SortOrder, ChapterSortComparerDefaultLast.Default);
}
return new SeriesDetailDto
@ -569,35 +558,35 @@ public class SeriesService : ISeriesService
/// <returns></returns>
private static bool ShouldIncludeChapter(ChapterDto chapter)
{
return !chapter.IsSpecial && !chapter.Number.Equals(Parser.DefaultChapter);
return !chapter.IsSpecial && chapter.MinNumber.IsNot(Parser.DefaultChapterNumber);
}
public static void RenameVolumeName(ChapterDto firstChapter, VolumeDto volume, LibraryType libraryType, string volumeLabel = "Volume")
public static bool RenameVolumeName(VolumeDto volume, LibraryType libraryType, string volumeLabel = "Volume")
{
// TODO: Move this into DB
// TODO: Move this into DB (not sure how because of localization and lookups)
if (libraryType is LibraryType.Book or LibraryType.LightNovel)
{
var firstChapter = volume.Chapters.First();
// On Books, skip volumes that are specials, since these will be shown
if (firstChapter.IsSpecial) return false;
if (string.IsNullOrEmpty(firstChapter.TitleName))
{
if (firstChapter.Range.Equals(Parser.LooseLeafVolume)) return;
if (firstChapter.Range.Equals(Parser.LooseLeafVolume)) return false;
var title = Path.GetFileNameWithoutExtension(firstChapter.Range);
if (string.IsNullOrEmpty(title)) return;
volume.Name += $" - {title}";
if (string.IsNullOrEmpty(title)) return false;
volume.Name += $" - {title}"; // OPDS smart list 7 (just pdfs) triggered this
}
else if (volume.Name != Parser.LooseLeafVolume)
else if (!volume.IsLooseLeaf())
{
// If the titleName has Volume inside it, let's just send that back?
volume.Name += $" - {firstChapter.TitleName}";
volume.Name = firstChapter.TitleName;
}
// else
// {
// volume.Name += $"";
// }
return;
return true;
}
volume.Name = $"{volumeLabel} {volume.Name}".Trim();
volume.Name = $"{volumeLabel.Trim()} {volume.Name}".Trim();
return true;
}
@ -783,16 +772,15 @@ public class SeriesService : ISeriesService
: (DateTime?)null;
// For number and volume number, we need the highest chapter, not the latest created
var lastChapter = chapters.MaxBy(c => c.Number.AsFloat())!;
float.TryParse(lastChapter.Number, NumberStyles.Number, CultureInfo.InvariantCulture,
out var lastChapterNumber);
var lastChapter = chapters.MaxBy(c => c.MaxNumber)!;
var lastChapterNumber = lastChapter.MaxNumber;
var lastVolumeNum = chapters.Select(c => c.Volume.MinNumber).Max();
var result = new NextExpectedChapterDto
{
ChapterNumber = 0,
VolumeNumber = 0,
VolumeNumber = Parser.LooseLeafVolumeNumber,
ExpectedDate = nextChapterExpected,
Title = string.Empty
};

View file

@ -336,7 +336,7 @@ public class StatisticService : IStatisticService
LibraryId = u.LibraryId,
ReadDate = u.LastModified,
ChapterId = u.ChapterId,
ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).Number
ChapterNumber = _context.Chapter.Single(c => c.Id == u.ChapterId).MinNumber
})
.OrderByDescending(d => d.ReadDate)
.ToListAsync();

View file

@ -14,10 +14,11 @@ using AutoMapper;
using Microsoft.Extensions.Logging;
namespace API.Services;
#nullable enable
public interface ITachiyomiService
{
Task<ChapterDto?> GetLatestChapter(int seriesId, int userId);
Task<TachiyomiChapterDto?> GetLatestChapter(int seriesId, int userId);
Task<bool> MarkChaptersUntilAsRead(AppUser userWithProgress, int seriesId, float chapterNumber);
}
@ -51,7 +52,7 @@ public class TachiyomiService : ITachiyomiService
/// If its a chapter, return the chapterDto as is.
/// If it's a volume, the volume number gets returned in the 'Number' attribute of a chapterDto encoded.
/// The volume number gets divided by 10,000 because that's how Tachiyomi interprets volumes</returns>
public async Task<ChapterDto?> GetLatestChapter(int seriesId, int userId)
public async Task<TachiyomiChapterDto?> GetLatestChapter(int seriesId, int userId)
{
var currentChapter = await _readerService.GetContinuePoint(seriesId, userId);
@ -74,50 +75,48 @@ public class TachiyomiService : ITachiyomiService
{
var volumeChapter = _mapper.Map<ChapterDto>(volumes
[^1].Chapters
.OrderBy(c => c.Number.AsFloat(), ChapterSortComparerZeroFirst.Default)
.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultFirst.Default)
.Last());
if (volumeChapter.Number == Parser.LooseLeafVolume)
if (volumeChapter.MinNumber.Is(Parser.LooseLeafVolumeNumber))
{
var volume = volumes.First(v => v.Id == volumeChapter.VolumeId);
return new ChapterDto()
{
// Use R to ensure that localization of underlying system doesn't affect the stringification
// https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework
Number = (volume.MinNumber / 10_000f).ToString("R", EnglishCulture)
};
return CreateTachiyomiChapterDto(volume.MinNumber);
}
return new ChapterDto()
{
Number = (int.Parse(volumeChapter.Number) / 10_000f).ToString("R", EnglishCulture)
};
return CreateTachiyomiChapterDto(volumeChapter.MinNumber);
}
var lastChapter = looseLeafChapterVolume.Chapters
.OrderBy(c => double.Parse(c.Number, CultureInfo.InvariantCulture), ChapterSortComparer.Default)
.OrderBy(c => c.MinNumber, ChapterSortComparerDefaultLast.Default)
.Last();
return _mapper.Map<ChapterDto>(lastChapter);
return _mapper.Map<TachiyomiChapterDto>(lastChapter);
}
// There is progress, we now need to figure out the highest volume or chapter and return that.
var prevChapter = (await _unitOfWork.ChapterRepository.GetChapterDtoAsync(prevChapterId))!;
var volumeWithProgress = await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId);
var volumeWithProgress = (await _unitOfWork.VolumeRepository.GetVolumeDtoAsync(prevChapter.VolumeId, userId))!;
// We only encode for single-file volumes
if (!volumeWithProgress!.IsLooseLeaf() && volumeWithProgress.Chapters.Count == 1)
if (!volumeWithProgress.IsLooseLeaf() && volumeWithProgress.Chapters.Count == 1)
{
// The progress is on a volume, encode it as a fake chapterDTO
return new ChapterDto()
{
// Use R to ensure that localization of underlying system doesn't affect the stringification
// https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework
Number = (volumeWithProgress.MinNumber / 10_000f).ToString("R", EnglishCulture)
};
return CreateTachiyomiChapterDto(volumeWithProgress.MinNumber);
}
// Progress is just on a chapter, return as is
return prevChapter;
return _mapper.Map<TachiyomiChapterDto>(prevChapter);
}
private static TachiyomiChapterDto CreateTachiyomiChapterDto(float number)
{
return new TachiyomiChapterDto()
{
// Use R to ensure that localization of underlying system doesn't affect the stringification
// https://docs.microsoft.com/en-us/globalization/locale/number-formatting-in-dotnet-framework
Number = (number / 10_000f).ToString("R", EnglishCulture)
};
}
/// <summary>

View file

@ -365,12 +365,68 @@ public class ParseScannedFiles
foreach (var series in scannedSeries.Keys)
{
if (scannedSeries[series].Count > 0 && processSeriesInfos != null)
if (scannedSeries[series].Count <= 0 || processSeriesInfos == null) continue;
UpdateSortOrder(scannedSeries, series);
await processSeriesInfos.Invoke(new Tuple<bool, IList<ParserInfo>>(false, scannedSeries[series]));
}
}
}
private void UpdateSortOrder(ConcurrentDictionary<ParsedSeries, List<ParserInfo>> scannedSeries, ParsedSeries series)
{
try
{
// Set the Sort order per Volume
var volumes = scannedSeries[series].GroupBy(info => info.Volumes);
foreach (var volume in volumes)
{
var infos = scannedSeries[series].Where(info => info.Volumes == volume.Key).ToList();
IList<ParserInfo> chapters;
var specialTreatment = infos.TrueForAll(info => info.IsSpecial);
if (specialTreatment)
{
await processSeriesInfos.Invoke(new Tuple<bool, IList<ParserInfo>>(false, scannedSeries[series]));
chapters = infos
.OrderBy(info => info.SpecialIndex)
.ToList();
}
else
{
chapters = infos
.OrderByNatural(info => info.Chapters)
.ToList();
}
var counter = 0f;
var prevIssue = string.Empty;
foreach (var chapter in chapters)
{
if (float.TryParse(chapter.Chapters, out var parsedChapter))
{
counter = parsedChapter;
if (!string.IsNullOrEmpty(prevIssue) && parsedChapter.Is(float.Parse(prevIssue)))
{
// Bump by 0.1
counter += 0.1f;
}
chapter.IssueOrder = counter;
prevIssue = $"{parsedChapter}";
}
else
{
chapter.IssueOrder = counter;
counter++;
prevIssue = chapter.Chapters;
}
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue setting IssueOrder");
}
}
/// <summary>

View file

@ -96,6 +96,7 @@ public class DefaultParser : IDefaultParser
if (Parser.HasSpecialMarker(fileName))
{
ret.IsSpecial = true;
ret.SpecialIndex = Parser.ParseSpecialIndex(fileName);
ret.Chapters = Parser.DefaultChapter;
ret.Volumes = Parser.LooseLeafVolume;
@ -113,6 +114,12 @@ public class DefaultParser : IDefaultParser
ret.Series = ret.Series.Substring(0, ret.Series.Length - ".pdf".Length);
}
// v0.8.x: Introducing a change where Specials will go in a separate Volume with a reserved number
if (ret.IsSpecial)
{
ret.Volumes = $"{Parser.SpecialVolumeNumber}";
}
return ret.Series == string.Empty ? null : ret;
}

View file

@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Linq;
@ -12,10 +13,16 @@ namespace API.Services.Tasks.Scanner.Parser;
public static class Parser
{
// NOTE: If you change this, don't forget to change in the UI (see Series Detail)
public const string DefaultChapter = "0"; // -2147483648
public const string LooseLeafVolume = "0";
public const int DefaultChapterNumber = 0;
public const int LooseLeafVolumeNumber = 0;
public const string DefaultChapter = "-100000"; // -2147483648
public const string LooseLeafVolume = "-100000";
public const int DefaultChapterNumber = -100_000;
public const int LooseLeafVolumeNumber = -100_000;
/// <summary>
/// The Volume Number of Specials to reside in
/// </summary>
public const int SpecialVolumeNumber = 100_000;
public const string SpecialVolume = "100000";
public static readonly TimeSpan RegexTimeout = TimeSpan.FromMilliseconds(500);
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg|\.webp|\.gif|\.avif)"; // Don't forget to update CoverChooser
@ -678,6 +685,13 @@ public static class Parser
return SpecialMarkerRegex.IsMatch(filePath);
}
public static int ParseSpecialIndex(string filePath)
{
var match = SpecialMarkerRegex.Match(filePath).Value.Replace("SP", string.Empty);
if (string.IsNullOrEmpty(match)) return 0;
return int.Parse(match);
}
public static bool IsMangaSpecial(string filePath)
{
filePath = ReplaceUnderscores(filePath);
@ -944,35 +958,52 @@ public static class Parser
{
try
{
if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
// Check if the range string is not null or empty
if (string.IsNullOrEmpty(range) || !Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
{
return (float) 0.0;
return 0.0f;
}
var tokens = range.Replace("_", string.Empty).Split("-");
return tokens.Min(t => t.AsFloat());
// Check if there is a range or not
if (Regex.IsMatch(range, @"\d-{1}\d"))
{
var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries);
return tokens.Min(t => t.AsFloat());
}
return float.Parse(range);
}
catch
catch (Exception)
{
return (float) 0.0;
return 0.0f;
}
}
public static float MaxNumberFromRange(string range)
{
try
{
if (!Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
// Check if the range string is not null or empty
if (string.IsNullOrEmpty(range) || !Regex.IsMatch(range, @"^[\d\-.]+$", MatchOptions, RegexTimeout))
{
return (float) 0.0;
return 0.0f;
}
var tokens = range.Replace("_", string.Empty).Split("-");
return tokens.Max(t => t.AsFloat());
// Check if there is a range or not
if (Regex.IsMatch(range, @"\d-{1}\d"))
{
var tokens = range.Replace("_", string.Empty).Split("-", StringSplitOptions.RemoveEmptyEntries);
return tokens.Max(t => t.AsFloat());
}
return float.Parse(range);
}
catch
catch (Exception)
{
return (float) 0.0;
return 0.0f;
}
}

View file

@ -60,6 +60,10 @@ public class ParserInfo
/// If the file contains no volume/chapter information or contains Special Keywords <see cref="Parser.MangaSpecialRegex"/>
/// </summary>
public bool IsSpecial { get; set; }
/// <summary>
/// If the file has a Special Marker explicitly, this will contain the index
/// </summary>
public int SpecialIndex { get; set; } = 0;
/// <summary>
/// Used for specials or books, stores what the UI should show.
@ -67,6 +71,12 @@ public class ParserInfo
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// This can be filled in from ComicInfo.xml during scanning. Will update the SortOrder field on <see cref="Entities.Chapter"/>.
/// Falls back to Parsed Chapter number
/// </summary>
public float IssueOrder { get; set; }
/// <summary>
/// If the ParserInfo has the IsSpecial tag or both volumes and chapters are default aka 0
/// </summary>

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
@ -219,14 +220,6 @@ public class ProcessSeries : IProcessSeries
_logger.LogCritical(ex,
"[ScannerService] There was an issue writing to the database for series {SeriesName}",
series.Name);
_logger.LogTrace("[ScannerService] Series Metadata Dump: {@Series}", series.Metadata);
_logger.LogTrace("[ScannerService] People Dump: {@People}", _people
.Select(p =>
new {p.Id, p.Name, SeriesMetadataIds =
p.SeriesMetadatas?.Select(m => m.Id),
ChapterMetadataIds =
p.ChapterMetadatas?.Select(m => m.Id)
.ToList()}));
await _eventHub.SendMessageAsync(MessageFactory.Error,
MessageFactory.ErrorEvent($"There was an issue writing to the DB for Series {series.OriginalName}",
@ -314,8 +307,8 @@ public class ProcessSeries : IProcessSeries
// The actual number of count's defined across all chapter's metadata
series.Metadata.MaxCount = chapters.Max(chapter => chapter.Count);
var maxVolume = series.Volumes.Max(v => (int) Parser.Parser.MaxNumberFromRange(v.Name));
var maxChapter = chapters.Max(c => (int) Parser.Parser.MaxNumberFromRange(c.Range));
var maxVolume = (int) series.Volumes.Max(v => v.MaxNumber);
var maxChapter = (int) chapters.Max(c => c.MaxNumber);
// Single books usually don't have a number in their Range (filename)
if (series.Format == MangaFormat.Epub || series.Format == MangaFormat.Pdf && chapters.Count == 1)
@ -544,10 +537,12 @@ public class ProcessSeries : IProcessSeries
Volume? volume;
try
{
volume = series.Volumes.SingleOrDefault(s => s.Name == volumeNumber);
// With the Name change to be formatted, Name no longer working because Name returns "1" and volumeNumber is "1.0", so we use LookupName as the original
volume = series.Volumes.SingleOrDefault(s => s.LookupName == volumeNumber);
}
catch (Exception ex)
{
// TODO: Push this to UI in some way
if (!ex.Message.Equals("Sequence contains more than one matching element")) throw;
_logger.LogCritical("[ScannerService] Kavita found corrupted volume entries on {SeriesName}. Please delete the series from Kavita via UI and rescan", series.Name);
throw new KavitaException(
@ -561,7 +556,8 @@ public class ProcessSeries : IProcessSeries
series.Volumes.Add(volume);
}
volume.Name = volumeNumber;
volume.LookupName = volumeNumber;
volume.Name = volume.GetNumberTitle();
_logger.LogDebug("[ScannerService] Parsing {SeriesName} - Volume {VolumeNumber}", series.Name, volume.Name);
var infos = parsedInfos.Where(p => p.Volumes == volumeNumber).ToArray();
@ -586,7 +582,9 @@ public class ProcessSeries : IProcessSeries
}
// Remove existing volumes that aren't in parsedInfos
var nonDeletedVolumes = series.Volumes.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.Name)).ToList();
var nonDeletedVolumes = series.Volumes
.Where(v => parsedInfos.Select(p => p.Volumes).Contains(v.LookupName))
.ToList();
if (series.Volumes.Count != nonDeletedVolumes.Count)
{
_logger.LogDebug("[ScannerService] Removed {Count} volumes from {SeriesName} where parsed infos were not mapping with volume name",
@ -597,8 +595,9 @@ public class ProcessSeries : IProcessSeries
var file = volume.Chapters.FirstOrDefault()?.Files?.FirstOrDefault()?.FilePath ?? string.Empty;
if (!string.IsNullOrEmpty(file) && _directoryService.FileSystem.File.Exists(file))
{
// This can happen when file is renamed and volume is removed
_logger.LogInformation(
"[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk. File: {File}",
"[ScannerService] Volume cleanup code was trying to remove a volume with a file still existing on disk (usually volume marker removed) File: {File}",
file);
}
@ -640,12 +639,19 @@ public class ProcessSeries : IProcessSeries
chapter.UpdateFrom(info);
}
if (chapter == null) continue;
if (chapter == null)
{
continue;
}
// Add files
var specialTreatment = info.IsSpecialInfo();
AddOrUpdateFileForChapter(chapter, info, forceUpdate);
// TODO: Investigate using the ChapterBuilder here
chapter.Number = Parser.Parser.MinNumberFromRange(info.Chapters).ToString(CultureInfo.InvariantCulture);
chapter.Range = specialTreatment ? info.Filename : info.Chapters;
chapter.MinNumber = Parser.Parser.MinNumberFromRange(info.Chapters);
chapter.MaxNumber = Parser.Parser.MaxNumberFromRange(info.Chapters);
chapter.SortOrder = info.IssueOrder;
chapter.Range = chapter.GetNumberTitle();
}
@ -655,7 +661,7 @@ public class ProcessSeries : IProcessSeries
{
if (existingChapter.Files.Count == 0 || !parsedInfos.HasInfo(existingChapter))
{
_logger.LogDebug("[ScannerService] 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.GetNumberTitle(), volume.Name, parsedInfos[0].Series);
volume.Chapters.Remove(existingChapter);
}
else
@ -680,6 +686,7 @@ public class ProcessSeries : IProcessSeries
if (!forceUpdate && !_fileService.HasFileBeenModifiedSince(existingFile.FilePath, existingFile.LastModified) && existingFile.Pages != 0) return;
existingFile.Pages = _readingItemService.GetNumberOfPages(info.FullFilePath, info.Format);
existingFile.Extension = fileInfo.Extension.ToLowerInvariant();
existingFile.FileName = Path.GetFileNameWithoutExtension(existingFile.FilePath);
existingFile.Bytes = fileInfo.Length;
// We skip updating DB here with last modified time so that metadata refresh can do it
}

View file

@ -247,11 +247,17 @@ public class Startup
// v0.7.14
await MigrateEmailTemplates.Migrate(directoryService, logger);
await MigrateVolumeNumber.Migrate(unitOfWork, dataContext, logger);
await MigrateWantToReadImport.Migrate(unitOfWork, directoryService, logger);
await MigrateVolumeNumber.Migrate(dataContext, logger);
await MigrateWantToReadImport.Migrate(unitOfWork, dataContext, directoryService, logger);
await MigrateManualHistory.Migrate(dataContext, logger);
await MigrateClearNightlyExternalSeriesRecords.Migrate(dataContext, logger);
// v0.8.0
await MigrateVolumeLookupName.Migrate(dataContext, unitOfWork, logger);
await MigrateChapterNumber.Migrate(dataContext, logger);
await MigrateMixedSpecials.Migrate(dataContext, unitOfWork, logger);
await MigrateChapterFields.Migrate(dataContext, unitOfWork, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);
installVersion.Value = BuildInfo.Version.ToString();