diff --git a/API.Tests/Extensions/SeriesFilterTests.cs b/API.Tests/Extensions/SeriesFilterTests.cs index 372ddb78c..577e17619 100644 --- a/API.Tests/Extensions/SeriesFilterTests.cs +++ b/API.Tests/Extensions/SeriesFilterTests.cs @@ -932,7 +932,8 @@ public class SeriesFilterTests : AbstractDbTest var seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), Substitute.For()); + Substitute.For(), Substitute.For(), + Substitute.For()); // Select 0 Rating var zeroRating = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2); diff --git a/API.Tests/Services/ReadingListServiceTests.cs b/API.Tests/Services/ReadingListServiceTests.cs index 6c24dd894..7157aa90f 100644 --- a/API.Tests/Services/ReadingListServiceTests.cs +++ b/API.Tests/Services/ReadingListServiceTests.cs @@ -583,6 +583,93 @@ public class ReadingListServiceTests 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(), + Libraries = new List + { + 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() + { + myTestReadingList, + mySecondTestReadingList, + myThirdTestReadingList, + }; + + + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {3, 4}, myTestReadingList); + await _readingListService.AddChaptersToReadingList(spiceAndWolf.Id, new List {1, 2}, myThirdTestReadingList); + await _readingListService.AddChaptersToReadingList(othersidePicnic.Id, new List {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 #region CalculateStartAndEndDates diff --git a/API.Tests/Services/SeriesServiceTests.cs b/API.Tests/Services/SeriesServiceTests.cs index 3ab8b700e..7500cb29a 100644 --- a/API.Tests/Services/SeriesServiceTests.cs +++ b/API.Tests/Services/SeriesServiceTests.cs @@ -57,7 +57,7 @@ public class SeriesServiceTests : AbstractDbTest _seriesService = new SeriesService(_unitOfWork, Substitute.For(), Substitute.For(), Substitute.For>(), - Substitute.For(), locService); + Substitute.For(), locService, Substitute.For()); } #region Setup diff --git a/API/Controllers/OPDSController.cs b/API/Controllers/OPDSController.cs index 4be2b8dc1..ee8972327 100644 --- a/API/Controllers/OPDSController.cs +++ b/API/Controllers/OPDSController.cs @@ -27,6 +27,7 @@ using AutoMapper; using Kavita.Common; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; using MimeTypes; namespace API.Controllers; @@ -36,6 +37,7 @@ namespace API.Controllers; [AllowAnonymous] public class OpdsController : BaseApiController { + private readonly ILogger _logger; private readonly IUnitOfWork _unitOfWork; private readonly IDownloadService _downloadService; private readonly IDirectoryService _directoryService; @@ -82,7 +84,7 @@ public class OpdsController : BaseApiController IDirectoryService directoryService, ICacheService cacheService, IReaderService readerService, ISeriesService seriesService, IAccountService accountService, ILocalizationService localizationService, - IMapper mapper) + IMapper mapper, ILogger logger) { _unitOfWork = unitOfWork; _downloadService = downloadService; @@ -93,6 +95,7 @@ public class OpdsController : BaseApiController _accountService = accountService; _localizationService = localizationService; _mapper = mapper; + _logger = logger; _xmlSerializer = new XmlSerializer(typeof(Feed)); _xmlOpenSearchSerializer = new XmlSerializer(typeof(OpenSearchDescription)); @@ -580,19 +583,31 @@ public class OpdsController : BaseApiController public async Task GetReadingListItems(int readingListId, string apiKey, [FromQuery] int pageNumber = 0) { 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 (userWithLists == null) return Unauthorized(); - var readingList = userWithLists.ReadingLists.SingleOrDefault(t => t.Id == readingListId); + if (!(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync()).EnableOpds) + { + 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) { 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); SetFeedId(feed, $"reading-list-{readingListId}"); diff --git a/API/Data/Repositories/ReadingListRepository.cs b/API/Data/Repositories/ReadingListRepository.cs index 23c9b2fa2..3b75dc10f 100644 --- a/API/Data/Repositories/ReadingListRepository.cs +++ b/API/Data/Repositories/ReadingListRepository.cs @@ -53,6 +53,7 @@ public interface IReadingListRepository Task RemoveReadingListsWithoutSeries(); Task GetReadingListByTitleAsync(string name, int userId, ReadingListIncludes includes = ReadingListIncludes.Items); Task> GetReadingListsByIds(IList ids, ReadingListIncludes includes = ReadingListIncludes.Items); + Task> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items); } public class ReadingListRepository : IReadingListRepository @@ -170,7 +171,14 @@ public class ReadingListRepository : IReadingListRepository .AsSplitQuery() .ToListAsync(); } - + public async Task> 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) diff --git a/API/Services/ReadingListService.cs b/API/Services/ReadingListService.cs index 25ebd2434..a1c5fe6df 100644 --- a/API/Services/ReadingListService.cs +++ b/API/Services/ReadingListService.cs @@ -50,6 +50,14 @@ public interface IReadingListService Task CreateReadingListsFromSeries(int libraryId, int seriesId); Task GenerateReadingListCoverImage(int readingListId); + /// + /// Check, and update if needed, all reading lists' AgeRating who contain the passed series + /// + /// The series whose age rating is being updated + /// The new (uncommited) age rating of the series + /// + /// This method does not commit changes + Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating); } /// @@ -96,7 +104,13 @@ public class ReadingListService : IReadingListService { title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}"; } - } else { + } + else if (item.VolumeNumber == Parser.SpecialVolume) + { + title = specialTitle; + } + else + { title = $"Volume {specialTitle}"; } } @@ -844,4 +858,22 @@ public class ReadingListService : IReadingListService 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; + } + } } diff --git a/API/Services/SeriesService.cs b/API/Services/SeriesService.cs index 282830276..22bc7ff1b 100644 --- a/API/Services/SeriesService.cs +++ b/API/Services/SeriesService.cs @@ -50,6 +50,7 @@ public class SeriesService : ISeriesService private readonly ILogger _logger; private readonly IScrobblingService _scrobblingService; private readonly ILocalizationService _localizationService; + private readonly IReadingListService _readingListService; private readonly NextExpectedChapterDto _emptyExpectedChapter = new NextExpectedChapterDto { @@ -59,7 +60,8 @@ public class SeriesService : ISeriesService }; public SeriesService(IUnitOfWork unitOfWork, IEventHub eventHub, ITaskScheduler taskScheduler, - ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService) + ILogger logger, IScrobblingService scrobblingService, ILocalizationService localizationService, + IReadingListService readingListService) { _unitOfWork = unitOfWork; _eventHub = eventHub; @@ -67,6 +69,7 @@ public class SeriesService : ISeriesService _logger = logger; _scrobblingService = scrobblingService; _localizationService = localizationService; + _readingListService = readingListService; } /// @@ -191,6 +194,7 @@ public class SeriesService : ISeriesService { series.Metadata.AgeRating = updateSeriesMetadataDto.SeriesMetadata?.AgeRating ?? AgeRating.Unknown; series.Metadata.AgeRatingLocked = true; + await _readingListService.UpdateReadingListAgeRatingForSeries(series.Id, series.Metadata.AgeRating); } else { diff --git a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html index 35984b63f..1cb11b1a8 100644 --- a/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html +++ b/UI/Web/src/app/reading-list/_components/reading-list-item/reading-list-item.component.html @@ -42,7 +42,7 @@ @if (item.releaseDate !== '0001-01-01T00:00:00') {
- Released: {{item.releaseDate | date:'longDate'}} + {{t('released-label', {date: item.releaseDate | date:'longDate'})}}
} diff --git a/UI/Web/src/app/volume-detail/volume-detail.component.ts b/UI/Web/src/app/volume-detail/volume-detail.component.ts index 0c99b547c..621b62e86 100644 --- a/UI/Web/src/app/volume-detail/volume-detail.component.ts +++ b/UI/Web/src/app/volume-detail/volume-detail.component.ts @@ -613,6 +613,7 @@ export class VolumeDetailComponent implements OnInit { }); break; case Action.AddToReadingList: + this.actionService.addVolumeToReadingList(this.volume!, this.seriesId); break; case Action.Download: if (this.downloadInProgress) return; diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index c2dcbe6c3..6a03ecec1 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -1731,7 +1731,8 @@ "reading-list-item": { "remove": "{{common.remove}}", - "read": "{{common.read}}" + "read": "{{common.read}}", + "released-label": "Released: {{date}}" }, "stream-list-item": {