Sort by Average Rating and Big Want to Read fix (#2672)

This commit is contained in:
Joe Milazzo 2024-02-01 06:23:45 -06:00 committed by GitHub
parent 03e7d38482
commit 1fd72ada36
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 3552 additions and 105 deletions

3
.gitignore vendored
View File

@ -527,8 +527,7 @@ API/config/stats/*
API/config/stats/app_stats.json
API/config/pre-metadata/
API/config/post-metadata/
API/config/relations-imported.csv
API/config/relations.csv
API/config/*.csv
API.Tests/TestResults/
UI/Web/.vscode/settings.json
/API.Tests/Services/Test Data/ArchiveService/CoverImages/output/*

View File

@ -488,15 +488,21 @@ public class CleanupServiceTests : AbstractDbTest
var user = new AppUser()
{
UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries",
WantToRead = new List<Series>()
{
s
}
};
_context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
// Add want to read
user.WantToRead = new List<AppUserWantToRead>()
{
new AppUserWantToRead()
{
SeriesId = s.Id
}
};
await _unitOfWork.CommitAsync();
await _readerService.MarkSeriesAsRead(user, s.Id);
await _unitOfWork.CommitAsync();

View File

@ -53,6 +53,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.1.0" />
<PackageReference Include="MailKit" Version="4.3.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>

View File

@ -1250,7 +1250,7 @@ public class OpdsController : BaseApiController
if (progress != null)
{
link.LastRead = progress.PageNum;
link.LastReadDate = progress.LastModifiedUtc.ToString("o"); // Adhere to ISO 8601
link.LastReadDate = progress.LastModifiedUtc.ToString("s"); // Adhere to ISO 8601
}
link.IsPageStream = true;
return link;

View File

@ -38,18 +38,16 @@ public class ServerController : BaseApiController
private readonly IStatsService _statsService;
private readonly ICleanupService _cleanupService;
private readonly IScannerService _scannerService;
private readonly IAccountService _accountService;
private readonly ITaskScheduler _taskScheduler;
private readonly IUnitOfWork _unitOfWork;
private readonly IEasyCachingProviderFactory _cachingProviderFactory;
private readonly ILocalizationService _localizationService;
private readonly IEmailService _emailService;
public ServerController(ILogger<ServerController> logger,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IScannerService scannerService, IAccountService accountService,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService,
IStatsService statsService, ICleanupService cleanupService, IScannerService scannerService,
ITaskScheduler taskScheduler, IUnitOfWork unitOfWork, IEasyCachingProviderFactory cachingProviderFactory,
ILocalizationService localizationService, IEmailService emailService)
ILocalizationService localizationService)
{
_logger = logger;
_backupService = backupService;
@ -58,12 +56,10 @@ public class ServerController : BaseApiController
_statsService = statsService;
_cleanupService = cleanupService;
_scannerService = scannerService;
_accountService = accountService;
_taskScheduler = taskScheduler;
_unitOfWork = unitOfWork;
_cachingProviderFactory = cachingProviderFactory;
_localizationService = localizationService;
_emailService = emailService;
}
/// <summary>
@ -180,11 +176,22 @@ public class ServerController : BaseApiController
}
}
/// <summary>
/// Checks for updates and pushes an event to the UI
/// </summary>
/// <remarks>Some users have websocket issues so this is not always reliable to alert the user</remarks>
[HttpGet("check-for-updates")]
public async Task<ActionResult> CheckForAnnouncements()
{
await _taskScheduler.CheckForUpdate();
return Ok();
}
/// <summary>
/// Checks for updates, if no updates that are > current version installed, returns null
/// </summary>
[HttpGet("check-update")]
public async Task<ActionResult<UpdateNotificationDto>> CheckForUpdates()
public async Task<ActionResult<UpdateNotificationDto?>> CheckForUpdates()
{
return Ok(await _versionUpdaterService.CheckForUpdate());
}
@ -268,14 +275,4 @@ public class ServerController : BaseApiController
return Ok();
}
/// <summary>
/// Checks for updates and pushes an event to the UI
/// </summary>
/// <returns></returns>
[HttpGet("check-for-updates")]
public async Task<ActionResult> CheckForAnnouncements()
{
await _taskScheduler.CheckForUpdate();
return Ok();
}
}

View File

@ -7,6 +7,7 @@ using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Filtering.v2;
using API.DTOs.WantToRead;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
@ -91,15 +92,15 @@ public class WantToReadController : BaseApiController
AppUserIncludes.WantToRead);
if (user == null) return Unauthorized();
var existingIds = user.WantToRead.Select(s => s.Id).ToList();
existingIds.AddRange(dto.SeriesIds);
var existingIds = user.WantToRead.Select(s => s.SeriesId).ToList();
var idsToAdd = dto.SeriesIds.Except(existingIds);
var idsToAdd = existingIds.Distinct().ToList();
var seriesToAdd = await _unitOfWork.SeriesRepository.GetSeriesByIdsAsync(idsToAdd);
foreach (var series in seriesToAdd)
foreach (var id in idsToAdd)
{
user.WantToRead.Add(series);
user.WantToRead.Add(new AppUserWantToRead()
{
SeriesId = id
});
}
if (!_unitOfWork.HasChanges()) return Ok();
@ -127,7 +128,9 @@ public class WantToReadController : BaseApiController
AppUserIncludes.WantToRead);
if (user == null) return Unauthorized();
user.WantToRead = user.WantToRead.Where(s => !dto.SeriesIds.Contains(s.Id)).ToList();
user.WantToRead = user.WantToRead
.Where(s => !dto.SeriesIds.Contains(s.SeriesId))
.ToList();
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync())

View File

@ -30,4 +30,8 @@ public enum SortField
/// Last time the user had any reading progress
/// </summary>
ReadProgress = 7,
/// <summary>
/// Kavita+ Only - External Average Rating
/// </summary>
AverageRating = 8
}

View File

@ -62,6 +62,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<ExternalRating> ExternalRating { get; set; } = null!;
public DbSet<ExternalSeriesMetadata> ExternalSeriesMetadata { get; set; } = null!;
public DbSet<ExternalRecommendation> ExternalRecommendation { get; set; } = null!;
public DbSet<ManualMigrationHistory> ManualMigrationHistory { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder builder)

View File

@ -0,0 +1,86 @@
using System;
using System.Threading.Tasks;
using API.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// Introduced in v0.7.14, will store history so that going forward, migrations can just check against the history
/// and I don't need to remove old migrations
/// </summary>
public static class MigrateManualHistory
{
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
{
logger.LogCritical(
"Running MigrateManualHistory migration - Please be patient, this may take some time. This is not an error");
if (await dataContext.ManualMigrationHistory.AnyAsync())
{
logger.LogCritical(
"Running MigrateManualHistory migration - Completed. This is not an error");
return;
}
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateUserLibrarySideNavStream",
ProductVersion = "0.7.9.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateSmartFilterEncoding",
ProductVersion = "0.7.11.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateLibrariesToHaveAllFileTypes",
ProductVersion = "0.7.11.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateEmailTemplates",
ProductVersion = "0.7.14.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateVolumeNumber",
ProductVersion = "0.7.14.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateWantToReadExport",
ProductVersion = "0.7.14.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateWantToReadImport",
ProductVersion = "0.7.14.0",
RanAt = DateTime.UtcNow
});
dataContext.ManualMigrationHistory.Add(new ManualMigrationHistory()
{
Name = "MigrateManualHistory",
ProductVersion = "0.7.14.0",
RanAt = DateTime.UtcNow
});
await dataContext.SaveChangesAsync();
logger.LogCritical(
"Running MigrateManualHistory migration - Completed. This is not an error");
}
}

View File

@ -0,0 +1,81 @@
using System.Globalization;
using System.IO;
using System.Threading.Tasks;
using API.Services;
using CsvHelper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.7.13.12/v0.7.14 - Want to read is extracted and saved in a csv
/// </summary>
/// <remarks>This must run BEFORE any DB migrations</remarks>
public static class MigrateWantToReadExport
{
public static async Task Migrate(DataContext dataContext, IDirectoryService directoryService, ILogger<Program> logger)
{
logger.LogCritical(
"Running MigrateWantToReadExport migration - Please be patient, this may take some time. This is not an error");
var columnExists = false;
await using var command = dataContext.Database.GetDbConnection().CreateCommand();
command.CommandText = "PRAGMA table_info('Series')";
await dataContext.Database.OpenConnectionAsync();
await using var result = await command.ExecuteReaderAsync();
while (await result.ReadAsync())
{
var columnName = result["name"].ToString();
if (columnName != "AppUserId") continue;
logger.LogInformation("Column 'AppUserId' exists in the 'Series' table. Running migration...");
// Your migration logic here
columnExists = true;
break;
}
await result.CloseAsync();
if (!columnExists)
{
logger.LogCritical(
"Running MigrateWantToReadExport migration - Completed. This is not an error");
return;
}
await using var command2 = dataContext.Database.GetDbConnection().CreateCommand();
command.CommandText = "Select AppUserId, Id from Series WHERE AppUserId IS NOT NULL ORDER BY AppUserId;";
await dataContext.Database.OpenConnectionAsync();
await using var result2 = await command.ExecuteReaderAsync();
await using var writer = new StreamWriter(Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv"));
await using var csvWriter = new CsvWriter(writer, CultureInfo.InvariantCulture);
// Write header
csvWriter.WriteField("AppUserId");
csvWriter.WriteField("Id");
await csvWriter.NextRecordAsync();
// Write data
while (await result2.ReadAsync())
{
var appUserId = result2["AppUserId"].ToString();
var id = result2["Id"].ToString();
csvWriter.WriteField(appUserId);
csvWriter.WriteField(id);
await csvWriter.NextRecordAsync();
}
await result2.CloseAsync();
writer.Close();
logger.LogCritical(
"Running MigrateWantToReadExport migration - Completed. This is not an error");
}
}

View File

@ -0,0 +1,60 @@
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data.Repositories;
using API.Entities;
using API.Services;
using CsvHelper;
using Microsoft.Extensions.Logging;
namespace API.Data.ManualMigrations;
/// <summary>
/// v0.7.13.12/v0.7.14 - Want to read is imported from a csv
/// </summary>
public static class MigrateWantToReadImport
{
public static async Task Migrate(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<Program> logger)
{
var importFile = Path.Join(directoryService.ConfigDirectory, "want-to-read-migration.csv");
var outputFile = Path.Join(directoryService.ConfigDirectory, "imported-want-to-read-migration.csv");
logger.LogCritical(
"Running MigrateWantToReadImport migration - Please be patient, this may take some time. This is not an error");
if (!File.Exists(importFile) || File.Exists(outputFile))
{
logger.LogCritical(
"Running MigrateWantToReadImport migration - Completed. This is not an error");
return;
}
using var reader = new StreamReader(importFile);
using var csvReader = new CsvReader(reader, CultureInfo.InvariantCulture);
// Read the records from the CSV file
await csvReader.ReadAsync();
csvReader.ReadHeader(); // Skip the header row
while (await csvReader.ReadAsync())
{
// Read the values of AppUserId and Id columns
var appUserId = csvReader.GetField<int>("AppUserId");
var seriesId = csvReader.GetField<int>("Id");
var user = await unitOfWork.UserRepository.GetUserByIdAsync(appUserId, AppUserIncludes.WantToRead);
if (user == null || user.WantToRead.Any(w => w.SeriesId == seriesId)) continue;
user.WantToRead.Add(new AppUserWantToRead()
{
SeriesId = seriesId
});
}
await unitOfWork.CommitAsync();
reader.Close();
File.WriteAllLines(outputFile, await File.ReadAllLinesAsync(importFile));
logger.LogCritical(
"Running MigrateWantToReadImport migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,106 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class WantToReadFix : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_Series_AppUserId",
table: "Series");
migrationBuilder.DropColumn(
name: "AppUserId",
table: "Series");
migrationBuilder.CreateTable(
name: "AppUserWantToRead",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserWantToRead", x => x.Id);
table.ForeignKey(
name: "FK_AppUserWantToRead_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_AppUserWantToRead_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ManualMigrationHistory",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ProductVersion = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true),
RanAt = table.Column<DateTime>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ManualMigrationHistory", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserWantToRead_AppUserId",
table: "AppUserWantToRead",
column: "AppUserId");
migrationBuilder.CreateIndex(
name: "IX_AppUserWantToRead_SeriesId",
table: "AppUserWantToRead",
column: "SeriesId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserWantToRead");
migrationBuilder.DropTable(
name: "ManualMigrationHistory");
migrationBuilder.AddColumn<int>(
name: "AppUserId",
table: "Series",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Series_AppUserId",
table: "Series",
column: "AppUserId");
migrationBuilder.AddForeignKey(
name: "FK_Series_AspNetUsers_AppUserId",
table: "Series",
column: "AppUserId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
}
}

View File

@ -602,6 +602,27 @@ namespace API.Data.Migrations
b.ToTable("AppUserTableOfContent");
});
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("SeriesId");
b.ToTable("AppUserWantToRead");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
@ -980,6 +1001,26 @@ namespace API.Data.Migrations
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.ManualMigrationHistory", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.Property<DateTime>("RanAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("ManualMigrationHistory");
});
modelBuilder.Entity("API.Entities.MediaError", b =>
{
b.Property<int>("Id")
@ -1551,9 +1592,6 @@ namespace API.Data.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int?>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("AvgHoursToRead")
.HasColumnType("INTEGER");
@ -1634,8 +1672,6 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("LibraryId");
b.ToTable("Series");
@ -2252,6 +2288,25 @@ namespace API.Data.Migrations
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.AppUserWantToRead", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("WantToRead")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
@ -2479,10 +2534,6 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany("WantToRead")
.HasForeignKey("AppUserId");
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")

View File

@ -363,8 +363,8 @@ public class SeriesRepository : ISeriesRepository
.Where(l => EF.Functions.Like(l.Name, $"%{searchQuery}%"))
.IsRestricted(QueryContext.Search)
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(l => l.Name.ToLower())
.Take(maxRecords)
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -383,8 +383,8 @@ public class SeriesRepository : ISeriesRepository
.Include(s => s.Library)
.AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(s => s.SortName!.ToLower())
.Take(maxRecords)
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
@ -420,8 +420,8 @@ public class SeriesRepository : ISeriesRepository
.Where(rl => EF.Functions.Like(rl.Title, $"%{searchQuery}%"))
.RestrictAgainstAgeRestriction(userRating)
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(r => r.NormalizedTitle)
.Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -431,7 +431,6 @@ public class SeriesRepository : ISeriesRepository
.Where(c => c.Promoted || isAdmin)
.RestrictAgainstAgeRestriction(userRating)
.OrderBy(s => s.NormalizedTitle)
.AsNoTracking()
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle)
@ -443,8 +442,8 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(sm => sm.People.Where(t => t.Name != null && EF.Functions.Like(t.Name, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.Take(maxRecords)
.OrderBy(p => p.NormalizedName)
.Take(maxRecords)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -453,8 +452,8 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(sm => sm.Genres.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.Take(maxRecords)
.OrderBy(t => t.NormalizedTitle)
.Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -463,8 +462,8 @@ public class SeriesRepository : ISeriesRepository
.SelectMany(sm => sm.Tags.Where(t => EF.Functions.Like(t.Title, $"%{searchQuery}%")))
.AsSplitQuery()
.Distinct()
.Take(maxRecords)
.OrderBy(t => t.NormalizedTitle)
.Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -482,8 +481,8 @@ public class SeriesRepository : ISeriesRepository
result.Files = await _context.MangaFile
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(f => f.FilePath)
.Take(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
@ -499,8 +498,8 @@ public class SeriesRepository : ISeriesRepository
)
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery()
.Take(maxRecords)
.OrderBy(c => c.TitleName)
.Take(maxRecords)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
@ -991,6 +990,8 @@ public class SeriesRepository : ISeriesRepository
SortField.TimeToRead => query.DoOrderBy(s => s.AvgHoursToRead, filter.SortOptions),
SortField.ReleaseYear => query.DoOrderBy(s => s.Metadata.ReleaseYear, filter.SortOptions),
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id).Select(p => p.LastModified).Max(), filter.SortOptions),
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), filter.SortOptions),
_ => query
};
@ -1043,7 +1044,9 @@ public class SeriesRepository : ISeriesRepository
var wantToReadStmt = filter.Statements.FirstOrDefault(stmt => stmt.Field == FilterField.WantToRead);
if (wantToReadStmt == null) return query;
var seriesIds = _context.AppUser.Where(u => u.Id == userId).SelectMany(u => u.WantToRead).Select(s => s.Id);
var seriesIds = _context.AppUser.Where(u => u.Id == userId)
.SelectMany(u => u.WantToRead)
.Select(s => s.SeriesId);
if (bool.Parse(wantToReadStmt.Value))
{
query = query.Where(s => seriesIds.Contains(s.Id));
@ -1869,7 +1872,8 @@ public class SeriesRepository : ISeriesRepository
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => libraryIds.Contains(s.Series.LibraryId))
.Select(w => w.Series)
.AsSplitQuery()
.AsNoTracking();
@ -1884,7 +1888,8 @@ public class SeriesRepository : ISeriesRepository
var query = _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => libraryIds.Contains(s.Series.LibraryId))
.Select(w => w.Series)
.AsSplitQuery()
.AsNoTracking();
@ -1899,7 +1904,8 @@ public class SeriesRepository : ISeriesRepository
return await _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead)
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => libraryIds.Contains(s.Series.LibraryId))
.Select(w => w.Series)
.AsSplitQuery()
.AsNoTracking()
.ToListAsync();
@ -1994,7 +2000,7 @@ public class SeriesRepository : ISeriesRepository
var libraryIds = await _context.Library.GetUserLibraries(userId).ToListAsync();
return await _context.AppUser
.Where(user => user.Id == userId)
.SelectMany(u => u.WantToRead.Where(s => s.Id == seriesId && libraryIds.Contains(s.LibraryId)))
.SelectMany(u => u.WantToRead.Where(s => s.SeriesId == seriesId && libraryIds.Contains(s.Series.LibraryId)))
.AsSplitQuery()
.AsNoTracking()
.AnyAsync();

View File

@ -31,7 +31,7 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
/// <summary>
/// A list of Series the user want's to read
/// </summary>
public ICollection<Series> WantToRead { get; set; } = null!;
public ICollection<AppUserWantToRead> WantToRead { get; set; } = null!;
/// <summary>
/// A list of Devices which allows the user to send files to
/// </summary>

View File

@ -0,0 +1,20 @@
namespace API.Entities;
public class AppUserWantToRead
{
public int Id { get; set; }
public required int SeriesId { get; set; }
public virtual Series Series { get; set; }
// Relationships
/// <summary>
/// Navigational Property for EF. Links to a unique AppUser
/// </summary>
public AppUser AppUser { get; set; } = null!;
/// <summary>
/// User this table of content belongs to
/// </summary>
public int AppUserId { get; set; }
}

View File

@ -0,0 +1,14 @@
using System;
namespace API.Entities;
/// <summary>
/// This will track manual migrations so that I can use simple selects to check if a Manual Migration is needed
/// </summary>
public class ManualMigrationHistory
{
public int Id { get; set; }
public string ProductVersion { get; set; }
public required string Name { get; set; }
public DateTime RanAt { get; set; }
}

View File

@ -37,6 +37,8 @@ public static class BookmarkSort
SortField.TimeToRead => query.DoOrderBy(s => s.Series.AvgHoursToRead, sortOptions),
SortField.ReleaseYear => query.DoOrderBy(s => s.Series.Metadata.ReleaseYear, sortOptions),
SortField.ReadProgress => query.DoOrderBy(s => s.Series.Progress.Where(p => p.SeriesId == s.Series.Id).Select(p => p.LastModified).Max(), sortOptions),
SortField.AverageRating => query.DoOrderBy(s => s.Series.ExternalSeriesMetadata.ExternalRatings
.Where(p => p.SeriesId == s.Series.Id).Average(p => p.AverageScore), sortOptions),
_ => query
};

View File

@ -33,6 +33,8 @@ public static class SeriesSort
SortField.ReadProgress => query.DoOrderBy(s => s.Progress.Where(p => p.SeriesId == s.Id && p.AppUserId == userId)
.Select(p => p.LastModified)
.Max(), sortOptions),
SortField.AverageRating => query.DoOrderBy(s => s.ExternalSeriesMetadata.ExternalRatings
.Where(p => p.SeriesId == s.Id).Average(p => p.AverageScore), sortOptions),
_ => query
};

View File

@ -73,8 +73,10 @@ public static class LogLevelOptions
if (isRequestLoggingMiddleware)
{
if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/api/health") return false;
if (e.Properties.ContainsKey("Path") && e.Properties["Path"].ToString().Replace("\"", string.Empty) == "/hubs/messages") return false;
var path = e.Properties["Path"].ToString().Replace("\"", string.Empty);
if (e.Properties.ContainsKey("Path") && path == "/api/health") return false;
if (e.Properties.ContainsKey("Path") && path == "/hubs/messages") return false;
if (e.Properties.ContainsKey("Path") && path.StartsWith("/api/image")) return false;
}
return true;

View File

@ -87,6 +87,30 @@ public class Program
}
}
// Apply Before manual migrations that need to run before actual migrations
try
{
Task.Run(async () =>
{
// Apply all migrations on startup
var dataContext = services.GetRequiredService<DataContext>();
var directoryService = services.GetRequiredService<IDirectoryService>();
logger.LogInformation("Running Migrations");
// v0.7.14
await MigrateWantToReadExport.Migrate(dataContext, directoryService, logger);
await unitOfWork.CommitAsync();
logger.LogInformation("Running Migrations - complete");
}).GetAwaiter()
.GetResult();
}
catch (Exception ex)
{
logger.LogCritical(ex, "An error occurred during migration");
}
await context.Database.MigrateAsync();

View File

@ -288,8 +288,8 @@ public class CleanupService : ICleanupService
var seriesIds = series.Select(s => s.Id).ToList();
if (seriesIds.Count == 0) continue;
user.WantToRead ??= new List<Series>();
user.WantToRead = user.WantToRead.Where(s => !seriesIds.Contains(s.Id)).ToList();
user.WantToRead ??= new List<AppUserWantToRead>();
user.WantToRead = user.WantToRead.Where(s => !seriesIds.Contains(s.SeriesId)).ToList();
_unitOfWork.UserRepository.Update(user);
}

View File

@ -248,6 +248,8 @@ public class Startup
// v0.7.14
await MigrateEmailTemplates.Migrate(directoryService, logger);
await MigrateVolumeNumber.Migrate(unitOfWork, dataContext, logger);
await MigrateWantToReadImport.Migrate(unitOfWork, directoryService, logger);
await MigrateManualHistory.Migrate(dataContext, logger);
// Update the version in the DB after all migrations are run
var installVersion = await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion);

View File

@ -14,6 +14,12 @@ function generateChecksum(str, algorithm, encoding) {
const result = {};
// Remove file if it exists
const cacheBustingFilePath = './i18n-cache-busting.json';
if (fs.existsSync(cacheBustingFilePath)) {
fs.unlinkSync(cacheBustingFilePath);
}
glob.sync(`${jsonFilesDir}**/*.json`).forEach(path => {
let tokens = path.split('dist\\browser\\assets\\langs\\');
if (tokens.length === 1) {

View File

@ -3,12 +3,12 @@
"version": "0.7.12.1",
"scripts": {
"ng": "ng",
"start": "npm run cache-langs && ng serve",
"build": "npm run cache-langs && ng build",
"start": "npm run cache-locale && ng serve",
"build": "npm run cache-locale && ng build",
"minify-langs": "node minify-json.js",
"cache-langs": "node hash-localization.js",
"cache-langs-prime": "node hash-localization-prime.js",
"prod": "npm run cache-langs-prime && ng build --configuration production && npm run minify-langs && npm run cache-langs",
"cache-locale": "node hash-localization.js",
"cache-locale-prime": "node hash-localization-prime.js",
"prod": "npm run cache-locale-prime && ng build --configuration production && npm run minify-langs && npm run cache-locale",
"explore": "ng build --stats-json && webpack-bundle-analyzer dist/stats.json",
"lint": "ng lint",
"e2e": "ng e2e"

View File

@ -21,6 +21,10 @@ export enum SortField {
TimeToRead = 5,
ReleaseYear = 6,
ReadProgress = 7,
/**
* Kavita+ only
*/
AverageRating = 8
}
export const allSortFields = Object.keys(SortField)

View File

@ -27,6 +27,8 @@ export class SortFieldPipe implements PipeTransform {
return this.translocoService.translate('sort-field-pipe.release-year');
case SortField.ReadProgress:
return this.translocoService.translate('sort-field-pipe.read-progress');
case SortField.AverageRating:
return this.translocoService.translate('sort-field-pipe.average-rating');
}
}

View File

@ -39,11 +39,16 @@ export class ServerService {
}
checkForUpdate() {
return this.http.get<UpdateVersionEvent>(this.baseUrl + 'server/check-update', {});
return this.http.get<UpdateVersionEvent | null>(this.baseUrl + 'server/check-update');
}
checkHowOutOfDate() {
return this.http.get<string>(this.baseUrl + 'server/checkHowOutOfDate', TextResonse)
.pipe(map(r => parseInt(r, 10)));
}
checkForUpdates() {
return this.http.get(this.baseUrl + 'server/check-for-updates', {});
return this.http.get<UpdateVersionEvent>(this.baseUrl + 'server/check-for-updates', {});
}
getChangelog() {

View File

@ -40,7 +40,7 @@ export class ManageEmailSettingsComponent implements OnInit {
ngOnInit(): void {
this.settingsService.getServerSettings().pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, []));
this.settingsForm.addControl('hostName', new FormControl(this.serverSettings.hostName, [Validators.pattern(/^(http:|https:)+[^\s]+[\w]$/)]));
this.settingsForm.addControl('host', new FormControl(this.serverSettings.smtpConfig.host, []));
this.settingsForm.addControl('port', new FormControl(this.serverSettings.smtpConfig.port, []));

View File

@ -5,6 +5,21 @@
<strong>{{t('notice')}}</strong> {{t('restart-required')}}
</div>
<div class="mb-3">
<label for="settings-hostname" class="form-label">{{t('host-name-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="hostNameTooltip" role="button" tabindex="0"></i>
<ng-template #hostNameTooltip>{{t('host-name-tooltip')}}</ng-template>
<span class="visually-hidden" id="settings-hostname-help">
<ng-container [ngTemplateOutlet]="hostNameTooltip"></ng-container>
</span>
<input id="settings-hostname" aria-describedby="settings-hostname-help" class="form-control" formControlName="hostName" type="text"
[class.is-invalid]="settingsForm.get('hostName')?.invalid && settingsForm.get('hostName')?.touched">
<div id="hostname-validations" class="invalid-feedback" *ngIf="settingsForm.dirty || settingsForm.touched">
<div *ngIf="settingsForm.get('hostName')?.errors?.pattern">
{{t('host-name-validation')}}
</div>
</div>
</div>
<div class="mb-3">
<label for="settings-baseurl" class="form-label">{{t('base-url-label')}}</label><i class="fa fa-info-circle ms-1" placement="right" [ngbTooltip]="baseUrlTooltip" role="button" tabindex="0"></i>
<ng-template #baseUrlTooltip>{{t('base-url-tooltip')}}</ng-template>

View File

@ -104,7 +104,6 @@ export class ManageSettingsComponent implements OnInit {
modelSettings.smtpConfig = this.serverSettings.smtpConfig;
this.settingsService.updateServerSettings(modelSettings).pipe(take(1)).subscribe((settings: ServerSettings) => {
this.serverSettings = settings;
this.resetForm();

View File

@ -21,7 +21,6 @@ export class ChangelogComponent implements OnInit {
constructor(private serverService: ServerService) { }
ngOnInit(): void {
this.serverService.getChangelog().subscribe(updates => {
this.updates = updates;
this.isLoading = false;

View File

@ -0,0 +1,18 @@
<ng-container *transloco="let t; read:'out-of-date-modal'">
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{t('title')}}</h4>
<button type="button" class="btn-close" [attr.aria-label]="t('close')" (click)="close()"></button>
</div>
<div class="modal-body">
<p><strong>{{t('subtitle', {count: versionsOutOfDate})}}</strong></p>
<p>{{t('description-1')}}</p>
<p [innerHTML]="t('description-2') | safeHtml"></p>
<p>{{t('description-3')}}</p>
</div>
<div class="modal-footer">
<a class="btn btn-link me-1" href="https://discord.gg/b52wT37kt7" target="_blank" rel="noreferrer noopener">Discord</a>
<button type="button" class="btn btn-primary" (click)="close()">{{t('close')}}</button>
</div>
</ng-container>

View File

@ -0,0 +1,38 @@
import {Component, DestroyRef, inject, Input} from '@angular/core';
import {FormsModule} from "@angular/forms";
import {AsyncPipe, NgForOf, NgIf} from "@angular/common";
import {NgbActiveModal, NgbHighlight, NgbModal, NgbTypeahead} from "@ng-bootstrap/ng-bootstrap";
import {TranslocoDirective} from "@ngneat/transloco";
import {ServerService} from "../../../_services/server.service";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {map} from "rxjs/operators";
import {ChangelogComponent} from "../changelog/changelog.component";
import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe";
@Component({
selector: 'app-out-of-date-modal',
standalone: true,
imports: [
FormsModule,
NgForOf,
NgIf,
NgbHighlight,
NgbTypeahead,
TranslocoDirective,
AsyncPipe,
ChangelogComponent,
SafeHtmlPipe
],
templateUrl: './out-of-date-modal.component.html',
styleUrl: './out-of-date-modal.component.scss'
})
export class OutOfDateModalComponent {
private readonly ngbModal = inject(NgbActiveModal);
@Input({required: true}) versionsOutOfDate: number = 0;
close() {
this.ngbModal.close();
}
}

View File

@ -13,6 +13,8 @@ import { SideNavComponent } from './sidenav/_components/side-nav/side-nav.compon
import {NavHeaderComponent} from "./nav/_components/nav-header/nav-header.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {ServerService} from "./_services/server.service";
import {ImportCblModalComponent} from "./reading-list/_modals/import-cbl-modal/import-cbl-modal.component";
import {OutOfDateModalComponent} from "./announcements/_components/out-of-date-modal/out-of-date-modal.component";
@Component({
selector: 'app-root',
@ -67,15 +69,6 @@ export class AppComponent implements OnInit {
});
// Every hour, have the UI check for an update. People seriously stay out of date
// interval(60 * 60 * 1000) // 60 minutes in milliseconds
// .pipe(
// switchMap(() => this.accountService.currentUser$),
// filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
// switchMap(_ => this.serverService.checkForUpdates())
// )
// .subscribe();
this.transitionState$ = this.accountService.currentUser$.pipe(
tap(user => {
@ -111,11 +104,21 @@ export class AppComponent implements OnInit {
// On load, make an initial call for valid license
this.accountService.hasValidLicense().subscribe();
interval(4 * 60 * 60 * 1000) // 4 hours in milliseconds
// Every hour, have the UI check for an update. People seriously stay out of date
interval(2* 60 * 60 * 1000) // 2 hours in milliseconds
.pipe(
switchMap(() => this.accountService.currentUser$),
filter(u => this.accountService.hasAdminRole(u!)),
switchMap(_ => this.serverService.checkForUpdates())
filter(u => u !== undefined && this.accountService.hasAdminRole(u)),
switchMap(_ => this.serverService.checkHowOutOfDate()),
filter(versionOutOfDate => {
return !isNaN(versionOutOfDate) && versionOutOfDate > 2;
}),
tap(versionOutOfDate => {
if (!this.ngbModal.hasOpenModals()) {
const ref = this.ngbModal.open(OutOfDateModalComponent, {size: 'xl', fullscreen: 'md'});
ref.componentInstance.versionsOutOfDate = 3;
}
})
)
.subscribe();
}

View File

@ -288,7 +288,7 @@ export class SeriesDetailComponent implements OnInit, AfterContentChecked {
if (this.activeTabId === TabID.Chapters) chapterArray = this.chapters;
// We must augment chapter indices as Bulk Selection assumes all on one page, but Storyline has mixed
const chapterIndexModifier = this.activeTabId === TabID.Storyline ? this.volumes.length + 1 : 0;
const chapterIndexModifier = this.activeTabId === TabID.Storyline ? this.volumes.length : 0;
const selectedChapterIds = chapterArray.filter((_chapter, index: number) => {
const mappedIndex = index + chapterIndexModifier;
return selectedChapterIndexes.includes(mappedIndex + '');

View File

@ -12,8 +12,9 @@ import { SentenceCasePipe } from '../../_pipes/sentence-case.pipe';
import { NgIf, NgFor } from '@angular/common';
import { EditDeviceComponent } from '../edit-device/edit-device.component';
import { NgbCollapse } from '@ng-bootstrap/ng-bootstrap';
import {TranslocoDirective} from "@ngneat/transloco";
import {translate, TranslocoDirective} from "@ngneat/transloco";
import {SettingsService} from "../../admin/settings.service";
import {ConfirmService} from "../../shared/confirm.service";
@Component({
selector: 'app-manage-devices',
@ -28,6 +29,7 @@ export class ManageDevicesComponent implements OnInit {
private readonly cdRef = inject(ChangeDetectorRef);
private readonly deviceService = inject(DeviceService);
private readonly settingsService = inject(SettingsService);
private readonly confirmService = inject(ConfirmService);
devices: Array<Device> = [];
addDeviceIsCollapsed: boolean = true;
@ -53,7 +55,8 @@ export class ManageDevicesComponent implements OnInit {
});
}
deleteDevice(device: Device) {
async deleteDevice(device: Device) {
if (!await this.confirmService.confirm(translate('toasts.delete-device'))) return;
this.deviceService.deleteDevice(device.id).subscribe(() => {
const index = this.devices.indexOf(device);
this.devices.splice(index, 1);

View File

@ -156,7 +156,10 @@ export class UserPreferencesComponent implements OnInit, OnDestroy {
this.accountService.hasValidLicense$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(res => {
if (res) {
this.tabs.push({title: 'scrobbling-tab', fragment: FragmentID.Scrobbling});
if (this.tabs.filter(t => t.fragment == FragmentID.Scrobbling).length === 0) {
this.tabs.push({title: 'scrobbling-tab', fragment: FragmentID.Scrobbling});
}
this.hasActiveLicense = true;
this.cdRef.markForCheck();
}

View File

@ -546,6 +546,15 @@
"title": "Announcements"
},
"out-of-date-modal": {
"title": "Don't fall behind!",
"close": "{{common.close}}",
"subtitle": "It seems your install is more than {{count}} versions behind!",
"description-1": "Please consider upgrading so that you're running the latest version of Kavita.",
"description-2": "Take a look at our <a href='https://wiki.kavitareader.com/guides/updating/' target='_blank' rel='noreferrer noopener'>wiki</a> for instructions on how to update.",
"description-3": "If there is a specific reason you haven't updated yet we would love to know what is keeping you on an outdated version! Stop by our discord and let us know what is blocking your upgrade path."
},
"changelog": {
"installed": "Installed",
"download": "Download",
@ -1190,6 +1199,9 @@
"folder-watching-label": "Folder Watching",
"folder-watching-tooltip": "Allows Kavita to monitor Library Folders to detect changes and invoke scanning on those changes. This allows content to be updated without manually invoking scans or waiting for nightly scans. Will always wait 10 minutes before triggering scan.",
"enable-folder-watching": "Enable Folder Watching",
"host-name-label": "{{manage-email-settings.host-name-label}}",
"host-name-tooltip": "{{manage-email-settings.host-name-tooltip}}",
"host-name-validation": "{{manage-email-settings.host-name-validation}}",
"reset-to-default": "{{common.reset-to-default}}",
@ -1643,7 +1655,8 @@
"last-chapter-added": "Item Added",
"time-to-read": "Time to Read",
"release-year": "Release Year",
"read-progress": "Last Read"
"read-progress": "Last Read",
"average-rating": "Average Rating"
},
"edit-series-modal": {
@ -2029,6 +2042,7 @@
"change-email-no-email": "Email has been updated",
"device-updated": "Device updated",
"device-created": "Device created",
"delete-device": "Are you sure you want to delete this device?",
"confirm-regen-covers": "Refresh covers will force all cover images to be recalculated. This is a heavy operation. Are you sure you don't want to perform a Scan instead?",
"alert-long-running": "This is a long running process. Please give it the time to complete before invoking again.",
"confirm-delete-multiple-series": "Are you sure you want to delete {{count}} series? It will not modify files on disk.",

View File

@ -7,7 +7,7 @@
"name": "GPL-3.0",
"url": "https://github.com/Kareadita/Kavita/blob/develop/LICENSE"
},
"version": "0.7.13.10"
"version": "0.7.13.11"
},
"servers": [
{
@ -9458,6 +9458,20 @@
}
}
},
"/api/Server/check-for-updates": {
"get": {
"tags": [
"Server"
],
"summary": "Checks for updates and pushes an event to the UI",
"description": "Some users have websocket issues so this is not always reliable to alert the user",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Server/check-update": {
"get": {
"tags": [
@ -9664,19 +9678,6 @@
}
}
},
"/api/Server/check-for-updates": {
"get": {
"tags": [
"Server"
],
"summary": "Checks for updates and pushes an event to the UI",
"responses": {
"200": {
"description": "Success"
}
}
}
},
"/api/Settings/base-url": {
"get": {
"tags": [
@ -12545,7 +12546,7 @@
"wantToRead": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Series"
"$ref": "#/components/schemas/AppUserWantToRead"
},
"description": "A list of Series the user want's to read",
"nullable": true
@ -13264,6 +13265,31 @@
"additionalProperties": false,
"description": "A personal table of contents for a given user linked with a given book"
},
"AppUserWantToRead": {
"type": "object",
"properties": {
"id": {
"type": "integer",
"format": "int32"
},
"seriesId": {
"type": "integer",
"format": "int32"
},
"series": {
"$ref": "#/components/schemas/Series"
},
"appUser": {
"$ref": "#/components/schemas/AppUser"
},
"appUserId": {
"type": "integer",
"description": "User this table of content belongs to",
"format": "int32"
}
},
"additionalProperties": false
},
"BookChapterItem": {
"type": "object",
"properties": {
@ -19034,7 +19060,8 @@
4,
5,
6,
7
7,
8
],
"type": "integer",
"format": "int32"
@ -20339,7 +20366,7 @@
},
"number": {
"type": "number",
"description": "This will map to MinNumber. Number was removed in v0.7.13.8",
"description": "This will map to MinNumber. Number was removed in v0.7.13.8/v0.7.14",
"format": "float",
"deprecated": true
},