Foundational Rework (#2745)
This commit is contained in:
parent
42cd6e9b3a
commit
4fa21fe1ca
92 changed files with 13330 additions and 650 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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!;
|
||||
}
|
||||
|
|
|
|||
12
API/DTOs/TachiyomiChapterDto.cs
Normal file
12
API/DTOs/TachiyomiChapterDto.cs
Normal 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!;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
140
API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs
Normal file
140
API/Data/ManualMigrations/ManualMigrateMixedSpecials.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
89
API/Data/ManualMigrations/MigrateChapterFields.cs
Normal file
89
API/Data/ManualMigrations/MigrateChapterFields.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
50
API/Data/ManualMigrations/MigrateChapterNumber.cs
Normal file
50
API/Data/ManualMigrations/MigrateChapterNumber.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
41
API/Data/ManualMigrations/MigrateVolumeLookupName.cs
Normal file
41
API/Data/ManualMigrations/MigrateVolumeLookupName.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
2877
API/Data/Migrations/20240214232436_ChapterNumber.Designer.cs
generated
Normal file
2877
API/Data/Migrations/20240214232436_ChapterNumber.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
40
API/Data/Migrations/20240214232436_ChapterNumber.cs
Normal file
40
API/Data/Migrations/20240214232436_ChapterNumber.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2880
API/Data/Migrations/20240216000223_MangaFileNameTemp.Designer.cs
generated
Normal file
2880
API/Data/Migrations/20240216000223_MangaFileNameTemp.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
28
API/Data/Migrations/20240216000223_MangaFileNameTemp.cs
Normal file
28
API/Data/Migrations/20240216000223_MangaFileNameTemp.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2883
API/Data/Migrations/20240222125420_ChapterIssueSort.Designer.cs
generated
Normal file
2883
API/Data/Migrations/20240222125420_ChapterIssueSort.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
29
API/Data/Migrations/20240222125420_ChapterIssueSort.cs
Normal file
29
API/Data/Migrations/20240222125420_ChapterIssueSort.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
2886
API/Data/Migrations/20240225235816_VolumeLookupName.Designer.cs
generated
Normal file
2886
API/Data/Migrations/20240225235816_VolumeLookupName.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
28
API/Data/Migrations/20240225235816_VolumeLookupName.cs
Normal file
28
API/Data/Migrations/20240225235816_VolumeLookupName.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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}";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
26
API/Extensions/FloatExtensions.cs
Normal file
26
API/Extensions/FloatExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ public class MangaFileBuilder : IEntityBuilder<MangaFile>
|
|||
Pages = pages,
|
||||
LastModified = File.GetLastWriteTime(filePath),
|
||||
LastModifiedUtc = File.GetLastWriteTimeUtc(filePath),
|
||||
FileName = Path.GetFileNameWithoutExtension(filePath)
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue