Reading List Detail Overhaul + More Bugfixes and Polish (#3687)

Co-authored-by: Yongun Seong <yseong.p@gmail.com>
This commit is contained in:
Joe Milazzo 2025-03-29 19:47:53 -05:00 committed by GitHub
parent b2ee651fb8
commit dad212bfb9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 5056 additions and 729 deletions

View file

@ -133,6 +133,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserPreferences>()
.Property(b => b.WantToReadSync)
.HasDefaultValue(true);
builder.Entity<AppUserPreferences>()
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.AllowScrobbling)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class AutomaticWebtoonReaderMode : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "AllowAutomaticWebtoonReaderDetection",
table: "AppUserPreferences",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowAutomaticWebtoonReaderDetection",
table: "AppUserPreferences");
}
}
}

View file

@ -15,7 +15,7 @@ namespace API.Data.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.1");
modelBuilder.HasAnnotation("ProductVersion", "9.0.3");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -353,6 +353,11 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("AllowAutomaticWebtoonReaderDetection")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true);
b.Property<bool>("AniListScrobblingEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@ -911,24 +916,6 @@ namespace API.Data.Migrations
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.ChapterPeople", b =>
{
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("ChapterId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("ChapterPeople");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
@ -1640,7 +1627,7 @@ namespace API.Data.Migrations
b.ToTable("MetadataFieldMapping");
});
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -1703,7 +1690,25 @@ namespace API.Data.Migrations
b.ToTable("MetadataSettings");
});
modelBuilder.Entity("API.Entities.Person", b =>
modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
{
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.HasKey("ChapterId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("ChapterPeople");
});
modelBuilder.Entity("API.Entities.Person.Person", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -1747,6 +1752,32 @@ namespace API.Data.Migrations
b.ToTable("Person");
});
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
{
b.Property<int>("SeriesMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<bool>("KavitaPlusConnection")
.HasColumnType("INTEGER");
b.Property<int>("OrderWeight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.HasKey("SeriesMetadataId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("SeriesMetadataPeople");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
{
b.Property<int>("Id")
@ -2111,32 +2142,6 @@ namespace API.Data.Migrations
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b =>
{
b.Property<int>("SeriesMetadataId")
.HasColumnType("INTEGER");
b.Property<int>("PersonId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<bool>("KavitaPlusConnection")
.HasColumnType("INTEGER");
b.Property<int>("OrderWeight")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.HasKey("SeriesMetadataId", "PersonId", "Role");
b.HasIndex("PersonId");
b.ToTable("SeriesMetadataPeople");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
@ -2804,25 +2809,6 @@ namespace API.Data.Migrations
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.ChapterPeople", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("People")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Person", "Person")
.WithMany("ChapterPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("Person");
});
modelBuilder.Entity("API.Entities.Device", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -2943,7 +2929,7 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.MetadataFieldMapping", b =>
{
b.HasOne("API.Entities.MetadataSettings", "MetadataSettings")
b.HasOne("API.Entities.MetadataMatching.MetadataSettings", "MetadataSettings")
.WithMany("FieldMappings")
.HasForeignKey("MetadataSettingsId")
.OnDelete(DeleteBehavior.Cascade)
@ -2952,6 +2938,44 @@ namespace API.Data.Migrations
b.Navigation("MetadataSettings");
});
modelBuilder.Entity("API.Entities.Person.ChapterPeople", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("People")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Person.Person", "Person")
.WithMany("ChapterPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("Person");
});
modelBuilder.Entity("API.Entities.Person.SeriesMetadataPeople", b =>
{
b.HasOne("API.Entities.Person.Person", "Person")
.WithMany("SeriesMetadataPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata")
.WithMany("People")
.HasForeignKey("SeriesMetadataId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ReadingList", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -3072,25 +3096,6 @@ namespace API.Data.Migrations
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadataPeople", b =>
{
b.HasOne("API.Entities.Person", "Person")
.WithMany("SeriesMetadataPeople")
.HasForeignKey("PersonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Metadata.SeriesMetadata", "SeriesMetadata")
.WithMany("People")
.HasForeignKey("SeriesMetadataId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Person");
b.Navigation("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
@ -3351,12 +3356,12 @@ namespace API.Data.Migrations
b.Navigation("People");
});
modelBuilder.Entity("API.Entities.MetadataSettings", b =>
modelBuilder.Entity("API.Entities.MetadataMatching.MetadataSettings", b =>
{
b.Navigation("FieldMappings");
});
modelBuilder.Entity("API.Entities.Person", b =>
modelBuilder.Entity("API.Entities.Person.Person", b =>
{
b.Navigation("ChapterPeople");

View file

@ -1,10 +1,7 @@
using System.Collections;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Person;
using API.Extensions;
@ -31,15 +28,13 @@ public interface IPersonRepository
Task<IList<PersonDto>> GetAllPersonDtosByRoleAsync(int userId, PersonRole role);
Task RemoveAllPeopleNoLongerAssociated();
Task<IList<PersonDto>> GetAllPeopleDtosForLibrariesAsync(int userId, List<int>? libraryIds = null);
Task<int> GetCountAsync();
Task<string> GetCoverImageAsync(int personId);
Task<string?> GetCoverImageAsync(int personId);
Task<string?> GetCoverImageByNameAsync(string name);
Task<IEnumerable<PersonRole>> GetRolesForPersonByName(int personId, int userId);
Task<PagedList<BrowsePersonDto>> GetAllWritersAndSeriesCount(int userId, UserParams userParams);
Task<Person?> GetPersonById(int personId);
Task<PersonDto?> GetPersonDtoByName(string name, int userId);
Task<Person> GetPersonByName(string name);
Task<bool> IsNameUnique(string name);
Task<IEnumerable<SeriesDto>> GetSeriesKnownFor(int personId);
@ -126,12 +121,8 @@ public class PersonRepository : IPersonRepository
.ToListAsync();
}
public async Task<int> GetCountAsync()
{
return await _context.Person.CountAsync();
}
public async Task<string> GetCoverImageAsync(int personId)
public async Task<string?> GetCoverImageAsync(int personId)
{
return await _context.Person
.Where(c => c.Id == personId)
@ -139,7 +130,7 @@ public class PersonRepository : IPersonRepository
.SingleOrDefaultAsync();
}
public async Task<string> GetCoverImageByNameAsync(string name)
public async Task<string?> GetCoverImageByNameAsync(string name)
{
var normalized = name.ToNormalized();
return await _context.Person
@ -208,7 +199,7 @@ public class PersonRepository : IPersonRepository
.FirstOrDefaultAsync();
}
public async Task<PersonDto> GetPersonDtoByName(string name, int userId)
public async Task<PersonDto?> GetPersonDtoByName(string name, int userId)
{
var normalized = name.ToNormalized();
var ageRating = await _context.AppUser.GetUserAgeRestriction(userId);
@ -220,11 +211,6 @@ public class PersonRepository : IPersonRepository
.FirstOrDefaultAsync();
}
public async Task<Person> GetPersonByName(string name)
{
return await _context.Person.FirstOrDefaultAsync(p => p.NormalizedName == name.ToNormalized());
}
public async Task<bool> IsNameUnique(string name)
{
return !(await _context.Person.AnyAsync(p => p.Name == name));

View file

@ -49,12 +49,14 @@ public interface IReadingListRepository
Task<IList<string>> GetRandomCoverImagesAsync(int readingListId);
Task<IList<string>> GetAllCoverImagesAsync();
Task<bool> ReadingListExists(string name);
IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId);
IEnumerable<PersonDto> GetReadingListPeopleAsync(int readingListId, PersonRole role);
Task<ReadingListCast> GetReadingListAllPeopleAsync(int readingListId);
Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<int> RemoveReadingListsWithoutSeries();
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>> GetReadingListsBySeriesId(int seriesId, ReadingListIncludes includes = ReadingListIncludes.Items);
Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId);
}
public class ReadingListRepository : IReadingListRepository
@ -121,12 +123,12 @@ public class ReadingListRepository : IReadingListRepository
.AnyAsync(x => x.NormalizedTitle != null && x.NormalizedTitle.Equals(normalized));
}
public IEnumerable<PersonDto> GetReadingListCharactersAsync(int readingListId)
public IEnumerable<PersonDto> GetReadingListPeopleAsync(int readingListId, PersonRole role)
{
return _context.ReadingListItem
.Where(item => item.ReadingListId == readingListId)
.SelectMany(item => item.Chapter.People)
.Where(p => p.Role == PersonRole.Character)
.Where(p => p.Role == role)
.OrderBy(p => p.Person.NormalizedName)
.Select(p => p.Person)
.Distinct()
@ -134,6 +136,77 @@ public class ReadingListRepository : IReadingListRepository
.AsEnumerable();
}
public async Task<ReadingListCast> GetReadingListAllPeopleAsync(int readingListId)
{
var allPeople = await _context.ReadingListItem
.Where(item => item.ReadingListId == readingListId)
.SelectMany(item => item.Chapter.People)
.OrderBy(p => p.Person.NormalizedName)
.Select(p => new
{
Role = p.Role,
Person = _mapper.Map<PersonDto>(p.Person)
})
.Distinct()
.ToListAsync();
// Create the ReadingListCast object
var cast = new ReadingListCast();
// Group people by role and populate the appropriate collections
foreach (var personGroup in allPeople.GroupBy(p => p.Role))
{
var people = personGroup.Select(pg => pg.Person).ToList();
switch (personGroup.Key)
{
case PersonRole.Writer:
cast.Writers = people;
break;
case PersonRole.CoverArtist:
cast.CoverArtists = people;
break;
case PersonRole.Publisher:
cast.Publishers = people;
break;
case PersonRole.Character:
cast.Characters = people;
break;
case PersonRole.Penciller:
cast.Pencillers = people;
break;
case PersonRole.Inker:
cast.Inkers = people;
break;
case PersonRole.Imprint:
cast.Imprints = people;
break;
case PersonRole.Colorist:
cast.Colorists = people;
break;
case PersonRole.Letterer:
cast.Letterers = people;
break;
case PersonRole.Editor:
cast.Editors = people;
break;
case PersonRole.Translator:
cast.Translators = people;
break;
case PersonRole.Team:
cast.Teams = people;
break;
case PersonRole.Location:
cast.Locations = people;
break;
case PersonRole.Other:
break;
}
}
return cast;
}
public async Task<IList<ReadingList>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{
var extension = encodeFormat.GetExtension();
@ -181,6 +254,33 @@ public class ReadingListRepository : IReadingListRepository
.ToListAsync();
}
/// <summary>
/// Returns a Partial ReadingListInfoDto. The HourEstimate needs to be calculated outside the repo
/// </summary>
/// <param name="readingListId"></param>
/// <returns></returns>
public async Task<ReadingListInfoDto?> GetReadingListInfoAsync(int readingListId)
{
// Get sum of these across all ReadingListItems: long wordCount, int pageCount, bool isEpub (assume false if any ReadingListeItem.Series.Format is non-epub)
var readingList = await _context.ReadingList
.Where(rl => rl.Id == readingListId)
.Include(rl => rl.Items)
.ThenInclude(item => item.Series)
.Include(rl => rl.Items)
.ThenInclude(item => item.Volume)
.Include(rl => rl.Items)
.ThenInclude(item => item.Chapter)
.Select(rl => new ReadingListInfoDto()
{
WordCount = rl.Items.Sum(item => item.Chapter.WordCount),
Pages = rl.Items.Sum(item => item.Chapter.Pages),
IsAllEpub = rl.Items.All(item => item.Series.Format == MangaFormat.Epub),
})
.FirstOrDefaultAsync();
return readingList;
}
public void Remove(ReadingListItem item)
{

View file

@ -167,11 +167,11 @@ public class ScrobbleRepository : IScrobbleRepository
var query = _context.ScrobbleEvent
.Where(e => e.AppUserId == userId)
.Include(e => e.Series)
.SortBy(filter.Field, filter.IsDescending)
.WhereIf(!string.IsNullOrEmpty(filter.Query), s =>
EF.Functions.Like(s.Series.Name, $"%{filter.Query}%")
)
.WhereIf(!filter.IncludeReviews, e => e.ScrobbleEventType != ScrobbleEventType.Review)
.SortBy(filter.Field, filter.IsDescending)
.AsSplitQuery()
.ProjectTo<ScrobbleEventDto>(_mapper.ConfigurationProvider);