A few reading list bug fixes (#3663)

This commit is contained in:
Fesaa 2025-03-22 20:39:09 +01:00 committed by GitHub
parent 0785d4afab
commit a7e1386bad
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 164 additions and 15 deletions

View File

@ -932,7 +932,8 @@ public class SeriesFilterTests : AbstractDbTest
var seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(), var seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>()); Substitute.For<IScrobblingService>(), Substitute.For<ILocalizationService>(),
Substitute.For<IReadingListService>());
// Select 0 Rating // Select 0 Rating
var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);

View File

@ -583,6 +583,93 @@ public class ReadingListServiceTests
Assert.Equal(AgeRating.G, readingList.AgeRating); Assert.Equal(AgeRating.G, readingList.AgeRating);
} }
[Fact]
public async Task UpdateReadingListAgeRatingForSeries()
{
await ResetDb();
var spiceAndWolf = new SeriesBuilder("Spice and Wolf")
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolumes([
new VolumeBuilder("1")
.WithChapters([
new ChapterBuilder("1").Build(),
new ChapterBuilder("2").Build(),
]).Build()
]).Build();
spiceAndWolf.Metadata.AgeRating = AgeRating.Everyone;
var othersidePicnic = new SeriesBuilder("Otherside Picnic ")
.WithMetadata(new SeriesMetadataBuilder().Build())
.WithVolumes([
new VolumeBuilder("1")
.WithChapters([
new ChapterBuilder("1").Build(),
new ChapterBuilder("2").Build(),
]).Build()
]).Build();
othersidePicnic.Metadata.AgeRating = AgeRating.Everyone;
_context.AppUser.Add(new AppUser()
{
UserName = "Amelia",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>
{
new LibraryBuilder("Test Library", LibraryType.LightNovel)
.WithSeries(spiceAndWolf)
.WithSeries(othersidePicnic)
.Build(),
},
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("Amelia", AppUserIncludes.ReadingLists);
Assert.NotNull(user);
var myTestReadingList = new ReadingListBuilder("MyReadingList").Build();
var mySecondTestReadingList = new ReadingListBuilder("MySecondReadingList").Build();
var myThirdTestReadingList = new ReadingListBuilder("MyThirdReadingList").Build();
user.ReadingLists = new List<ReadingList>()
{
myTestReadingList,
mySecondTestReadingList,
myThirdTestReadingList,
};
await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List<int> {1, 2}, myTestReadingList);
await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List<int> {3, 4}, myTestReadingList);
await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List<int> {1, 2}, myThirdTestReadingList);
await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List<int> {3, 4}, mySecondTestReadingList);
_unitOfWork.UserRepository.Update(user);
await _unitOfWork.CommitAsync();
await _readingListService.CalculateReadingListAgeRating(myTestReadingList);
await _readingListService.CalculateReadingListAgeRating(mySecondTestReadingList);
Assert.Equal(AgeRating.Everyone, myTestReadingList.AgeRating);
Assert.Equal(AgeRating.Everyone, mySecondTestReadingList.AgeRating);
Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating);
await _readingListService.UpdateReadingListAgeRatingForSeries(othersidePicnic.Id, AgeRating.Mature);
await _unitOfWork.CommitAsync();
// Reading lists containing Otherside Picnic are updated
myTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
Assert.NotNull(myTestReadingList);
Assert.Equal(AgeRating.Mature, myTestReadingList.AgeRating);
mySecondTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(2);
Assert.NotNull(mySecondTestReadingList);
Assert.Equal(AgeRating.Mature, mySecondTestReadingList.AgeRating);
// Unrelated reading list is not updated
myThirdTestReadingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(3);
Assert.NotNull(myThirdTestReadingList);
Assert.Equal(AgeRating.Everyone, myThirdTestReadingList.AgeRating);
}
#endregion #endregion
#region CalculateStartAndEndDates #region CalculateStartAndEndDates

View File

@ -57,7 +57,7 @@ public class SeriesServiceTests : AbstractDbTest
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(), _seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(), Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>(),
Substitute.For<IScrobblingService>(), locService); Substitute.For<IScrobblingService>(), locService, Substitute.For<IReadingListService>());
} }
#region Setup #region Setup

View File

@ -27,6 +27,7 @@ using AutoMapper;
using Kavita.Common; using Kavita.Common;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using MimeTypes; using MimeTypes;
namespace API.Controllers; namespace API.Controllers;
@ -36,6 +37,7 @@ namespace API.Controllers;
[AllowAnonymous] [AllowAnonymous]
public class OpdsController : BaseApiController public class OpdsController : BaseApiController
{ {
private readonly ILogger<OpdsController> _logger;
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IDownloadService _downloadService; private readonly IDownloadService _downloadService;
private readonly IDirectoryService _directoryService; private readonly IDirectoryService _directoryService;
@ -82,7 +84,7 @@ public class OpdsController : BaseApiController
IDirectoryService directoryService, ICacheService cacheService, IDirectoryService directoryService, ICacheService cacheService,
IReaderService readerService, ISeriesService seriesService, IReaderService readerService, ISeriesService seriesService,
IAccountService accountService, ILocalizationService localizationService, IAccountService accountService, ILocalizationService localizationService,
IMapper mapper) IMapper mapper, ILogger<OpdsController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_downloadService = downloadService; _downloadService = downloadService;
@ -93,6 +95,7 @@ public class OpdsController : BaseApiController
_accountService = accountService; _accountService = accountService;
_localizationService = localizationService; _localizationService = localizationService;
_mapper = mapper; _mapper = mapper;
_logger = logger;
_xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlSerializer = new XmlSerializer(typeof(Feed));
_xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription));
@ -580,19 +583,31 @@ public class OpdsController : BaseApiController
public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) public async Task<IActionResult> GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0)
{ {
var userId = await GetUser(apiKey); var userId = await GetUser(apiKey);
if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
var (baseUrl, prefix) = await GetPrefix();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
var userWithLists = await _unitOfWork.UserRepository.GetUserByUsernameAsync(user!.UserName!, AppUserIncludes.ReadingListsWithItems); if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds)
if (userWithLists == null) return Unauthorized(); {
var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); return BadRequest(await _localizationService.Translate(userId, "opds-disabled"));
}
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId);
if (user == null)
{
return Unauthorized();
}
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListDtosForUserAsync(user.Id, true, GetUserParams(pageNumber), false);
if (readingLists == null)
{
return Unauthorized();
}
var readingList = readingLists.FirstOrDefault(rl => rl.Id == readingListId);
if (readingList == null) if (readingList == null)
{ {
return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted")); return BadRequest(await _localizationService.Translate(userId, "reading-list-restricted"));
} }
var (baseUrl, prefix) = await GetPrefix();
var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix); var feed = CreateFeed(readingList.Title + " " + await _localizationService.Translate(userId, "reading-list"), $"{apiKey}/reading-list/{readingListId}", apiKey, prefix);
SetFeedId(feed, $"reading-list-{readingListId}"); SetFeedId(feed, $"reading-list-{readingListId}");

View File

@ -53,6 +53,7 @@ public interface IReadingListRepository
Task<int> RemoveReadingListsWithoutSeries(); Task<int> RemoveReadingListsWithoutSeries();
Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task<ReadingList?> GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items); Task<IEnumerable<ReadingList>> GetReadingListsByIds(IList<int> ids, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items);
} }
public class ReadingListRepository : IReadingListRepository public class ReadingListRepository : IReadingListRepository
@ -170,7 +171,14 @@ public class ReadingListRepository : IReadingListRepository
.AsSplitQuery() .AsSplitQuery()
.ToListAsync(); .ToListAsync();
} }
public async Task<IEnumerable<ReadingList>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items)
{
return await _context.ReadingList
.Where(rl => rl.Items.Any(rli => rli.SeriesId == seriesId))
.Includes(includes)
.AsSplitQuery()
.ToListAsync();
}
public void Remove(ReadingListItem item) public void Remove(ReadingListItem item)

View File

@ -50,6 +50,14 @@ public interface IReadingListService
Task CreateReadingListsFromSeries(int libraryId, int seriesId); Task CreateReadingListsFromSeries(int libraryId, int seriesId);
Task<string> GenerateReadingListCoverImage(int readingListId); Task<string> GenerateReadingListCoverImage(int readingListId);
/// <summary>
/// Check, and update if needed, all reading lists' AgeRating who contain the passed series
/// </summary>
/// <param name="seriesId">The series whose age rating is being updated</param>
/// <param name="ageRating">The new (uncommited) age rating of the series</param>
/// <returns></returns>
/// <remarks>This method does not commit changes</remarks>
Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating);
} }
/// <summary> /// <summary>
@ -96,7 +104,13 @@ public class ReadingListService : IReadingListService
{ {
title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}"; title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}";
} }
} else { }
else if (item.VolumeNumber == Parser.SpecialVolume)
{
title = specialTitle;
}
else
{
title = $"Volume {specialTitle}"; title = $"Volume {specialTitle}";
} }
} }
@ -844,4 +858,22 @@ public class ReadingListService : IReadingListService
return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile; return !_directoryService.FileSystem.File.Exists(destFile) ? string.Empty : destFile;
} }
public async Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating)
{
var readingLists = await _unitOfWork.ReadingListRepository.GetReadingListsBySeriesId(seriesId);
foreach (var readingList in readingLists)
{
var seriesIds = readingList.Items.Select(item => item.SeriesId).ToList();
seriesIds.Remove(seriesId); // Don't get AgeRating from database
var maxAgeRating = await _unitOfWork.SeriesRepository.GetMaxAgeRatingFromSeriesAsync(seriesIds);
if (ageRating > maxAgeRating)
{
maxAgeRating = ageRating;
}
readingList.AgeRating = maxAgeRating;
}
}
} }

View File

@ -50,6 +50,7 @@ public class SeriesService : ISeriesService
private readonly ILogger<SeriesService> _logger; private readonly ILogger<SeriesService> _logger;
private readonly IScrobblingService _scrobblingService; private readonly IScrobblingService _scrobblingService;
private readonly ILocalizationService _localizationService; private readonly ILocalizationService _localizationService;
private readonly IReadingListService _readingListService;
private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto
{ {
@ -59,7 +60,8 @@ public class SeriesService : ISeriesService
}; };
public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler,
ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService) ILogger<SeriesService> logger, IScrobblingService scrobblingService, ILocalizationService localizationService,
IReadingListService readingListService)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_eventHub = eventHub; _eventHub = eventHub;
@ -67,6 +69,7 @@ public class SeriesService : ISeriesService
_logger = logger; _logger = logger;
_scrobblingService = scrobblingService; _scrobblingService = scrobblingService;
_localizationService = localizationService; _localizationService = localizationService;
_readingListService = readingListService;
} }
/// <summary> /// <summary>
@ -191,6 +194,7 @@ public class SeriesService : ISeriesService
{ {
series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown;
series.Metadata.AgeRatingLocked = true; series.Metadata.AgeRatingLocked = true;
await _readingListService.UpdateReadingListAgeRatingForSeries(series.Id, series.Metadata.AgeRating);
} }
else else
{ {

View File

@ -42,7 +42,7 @@
@if (item.releaseDate !== '0001-01-01T00:00:00') { @if (item.releaseDate !== '0001-01-01T00:00:00') {
<div class="ps-1 mt-2"> <div class="ps-1 mt-2">
Released: {{item.releaseDate | date:'longDate'}} {{t('released-label', {date: item.releaseDate | date:'longDate'})}}
</div> </div>
} }
</div> </div>

View File

@ -613,6 +613,7 @@ export class VolumeDetailComponent implements OnInit {
}); });
break; break;
case Action.AddToReadingList: case Action.AddToReadingList:
this.actionService.addVolumeToReadingList(this.volume!, this.seriesId);
break; break;
case Action.Download: case Action.Download:
if (this.downloadInProgress) return; if (this.downloadInProgress) return;

View File

@ -1731,7 +1731,8 @@
"reading-list-item": { "reading-list-item": {
"remove": "{{common.remove}}", "remove": "{{common.remove}}",
"read": "{{common.read}}" "read": "{{common.read}}",
"released-label": "Released: {{date}}"
}, },
"stream-list-item": { "stream-list-item": {