No more JWTs for Scripts + Polish (#4274)

Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
Joe Milazzo 2025-12-13 06:55:02 -07:00 committed by GitHub
parent b67680c639
commit 8043650aa5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
131 changed files with 7804 additions and 1849 deletions

7
.gitignore vendored
View File

@ -515,10 +515,9 @@ UI/Web/dist/
/API/config/bookmarks/
/API/config/favicons/
/API/config/cache-long/
/API/config/kavita.db
/API/config/kavita.db-shm
/API/config/kavita.db-wal
/API/config/kavita.db-journal
/API/config/*.db-shm
/API/config/*.db-wal
/API/config/*.db-journal
/API/config/*.db
/API/config/*.bak
/API/config/*.backup

View File

@ -16,39 +16,44 @@ using NSubstitute;
using Xunit.Abstractions;
namespace API.Tests;
#nullable enable
public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): AbstractFsTest
public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): AbstractFsTest, IAsyncDisposable
{
private SqliteConnection? _connection;
private DataContext? _context;
protected async Task<(IUnitOfWork, DataContext, IMapper)> CreateDatabase()
{
// Dispose any previous connection if CreateDatabase is called multiple times
if (_connection != null)
{
await _context!.DisposeAsync();
await _connection.DisposeAsync();
}
_connection = new SqliteConnection("Filename=:memory:");
await _connection.OpenAsync();
var contextOptions = new DbContextOptionsBuilder<DataContext>()
.UseSqlite(CreateInMemoryDatabase())
.UseSqlite(_connection)
.EnableSensitiveDataLogging()
.Options;
var context = new DataContext(contextOptions);
_context = new DataContext(contextOptions);
await context.Database.EnsureCreatedAsync();
await _context.Database.EnsureCreatedAsync();
await SeedDb(context);
await SeedDb(_context);
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
GlobalConfiguration.Configuration.UseInMemoryStorage();
var unitOfWork = new UnitOfWork(context, mapper, null);
var unitOfWork = new UnitOfWork(_context, mapper, null);
return (unitOfWork, context, mapper);
}
private static SqliteConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
return (unitOfWork, _context, mapper);
}
private async Task<bool> SeedDb(DataContext context)
@ -96,7 +101,7 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra
/// </summary>
/// <param name="userId"></param>
/// <param name="roleName"></param>
protected async Task AddUserWithRole(DataContext context, int userId, string roleName)
protected static async Task AddUserWithRole(DataContext context, int userId, string roleName)
{
var role = new AppRole { Id = userId, Name = roleName, NormalizedName = roleName.ToUpper() };
@ -106,4 +111,19 @@ public abstract class AbstractDbTest(ITestOutputHelper testOutputHelper): Abstra
await context.SaveChangesAsync();
}
public async ValueTask DisposeAsync()
{
if (_context != null)
{
await _context.DisposeAsync();
}
if (_connection != null)
{
await _connection.DisposeAsync();
}
GC.SuppressFinalize(this);
}
}

View File

@ -1,10 +1,9 @@
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using API.Services.Tasks.Scanner.Parser;
namespace API.Tests;
#nullable enable
public abstract class AbstractFsTest
{

View File

@ -92,4 +92,26 @@ public class ComicInfoTests
#endregion
#region ASIN/ISBN/GTIN
[Theory]
[InlineData("0-306-40615-2")] // ISBN-10
[InlineData("978-0-306-40615-7")] // ISBN-13
[InlineData("99921-58-10-7")]
[InlineData("85-359-0277-5")]
public void IsValid(string code)
{
// Note: ASIN's starting with "B0" are not able to be converted to ISBN
Assert.Equal(code, ComicInfo.ParseGtin(code));
}
[Theory]
[InlineData("001234567890")]
[InlineData("9504000059437 ")]
public void IsInvalid(string code)
{
Assert.Equal(string.Empty, ComicInfo.ParseGtin(code));
}
#endregion
}

View File

@ -52,7 +52,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
@ -60,7 +59,8 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(), Substitute.For<IScrobblingService>(),
Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>());
Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>(),
Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
// Select Partial and set pages read to 5 on first chapter
var partialSeries = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(2);
@ -195,14 +195,14 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
Substitute.For<IScrobblingService>(),
Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>());
Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>(),
Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
// Set progress to 99.99% (99/100 pages read)
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(1);
@ -255,7 +255,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;
@ -397,7 +396,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;
@ -547,7 +545,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;
@ -673,7 +670,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;
@ -849,7 +845,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;
@ -982,7 +977,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
var ratingService = new RatingService(unitOfWork, Substitute.For<IScrobblingService>(), Substitute.For<ILogger<RatingService>>());
@ -1164,7 +1158,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;
@ -1305,7 +1298,6 @@ public class SeriesFilterTests(ITestOutputHelper outputHelper): AbstractDbTest(o
.Build();
context.Users.Add(user);
context.Library.Add(library);
await context.SaveChangesAsync();
return user;

View File

@ -361,6 +361,7 @@ public class MangaParsingTests
[InlineData("หนึ่งความคิด นิจนิรันดร์ บทที่ 112", "112")]
[InlineData("Monster #8 Ch. 001", "1")]
[InlineData("Monster Ch. 001 [MangaPlus] [Digital] [amit34521]", "1")]
[InlineData("Naruto v2.5", Parser.DefaultChapter)]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, Parser.ParseChapter(filename, LibraryType.Manga));

View File

@ -93,30 +93,32 @@ Substitute.For<IMediaConversionService>());
series.Library = new LibraryBuilder("Test LIb").Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "Joe",
Bookmarks = new List<AppUserBookmark>()
{
new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
}
}
UserName = "Joe"
});
await context.SaveChangesAsync();
// Now add the bookmark after we have valid IDs
var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
user.Bookmarks.Add(new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1,
AppUserId = 1
});
await context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var bookmarkService = Create(ds, unitOfWork);
var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
// Reload user to get the bookmark
user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
var result = await bookmarkService.RemoveBookmarkPage(user, new BookmarkDto()
{
@ -126,7 +128,6 @@ Substitute.For<IMediaConversionService>());
VolumeId = 1
});
Assert.True(result);
Assert.Empty(ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories));
Assert.Null(await unitOfWork.UserRepository.GetBookmarkAsync(1));
@ -136,8 +137,8 @@ Substitute.For<IMediaConversionService>());
#region DeleteBookmarkFiles
[Fact]
public async Task DeleteBookmarkFiles_ShouldDeleteOnlyPassedFiles()
[Fact]
public async Task DeleteBookmarkFiles_ShouldDeleteOnlyPassedFiles()
{
var (unitOfWork, context, _) = await CreateDatabase();
@ -158,41 +159,44 @@ Substitute.For<IMediaConversionService>());
series.Library = new LibraryBuilder("Test LIb").Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "Joe",
Bookmarks = new List<AppUserBookmark>()
{
new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
},
new AppUserBookmark()
{
Page = 2,
ChapterId = 1,
FileName = $"1/2/1/0002.jpg",
SeriesId = 2,
VolumeId = 1
},
new AppUserBookmark()
{
Page = 1,
ChapterId = 2,
FileName = $"1/2/1/0001.jpg",
SeriesId = 2,
VolumeId = 1
}
}
UserName = "Joe"
});
await context.SaveChangesAsync();
// Add bookmarks after entities are saved
var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
user.Bookmarks.Add(new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1,
AppUserId = 1
});
user.Bookmarks.Add(new AppUserBookmark()
{
Page = 2,
ChapterId = 1,
FileName = $"1/2/1/0002.jpg",
SeriesId = 1,
VolumeId = 1,
AppUserId = 1
});
user.Bookmarks.Add(new AppUserBookmark()
{
Page = 3,
ChapterId = 1,
FileName = $"1/2/1/0001.jpg",
SeriesId = 1,
VolumeId = 1,
AppUserId = 1
});
await context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var bookmarkService = Create(ds, unitOfWork);
@ -200,15 +204,14 @@ Substitute.For<IMediaConversionService>());
await bookmarkService.DeleteBookmarkFiles([
new AppUserBookmark
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
}
Page = 1,
ChapterId = 1,
FileName = $"1/1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
}
]);
Assert.Equal(2, ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories).Count());
Assert.False(ds.FileSystem.FileInfo.New(Path.Join(BookmarkDirectory, "1/1/1/0001.jpg")).Exists);
}
@ -265,112 +268,4 @@ Substitute.For<IMediaConversionService>());
#endregion
#region Misc
[Fact]
public async Task ShouldNotDeleteBookmark_OnChapterDeletion()
{
var filesystem = CreateFileSystem();
filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123"));
filesystem.AddFile($"{BookmarkDirectory}1/1/0001.jpg", new MockFileData("123"));
var (unitOfWork, context, _) = await CreateDatabase();
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
.WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb").Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "Joe",
Bookmarks = new List<AppUserBookmark>()
{
new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
}
}
});
await context.SaveChangesAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
var vol = await unitOfWork.VolumeRepository.GetVolumeAsync(1);
vol.Chapters = new List<Chapter>();
unitOfWork.VolumeRepository.Update(vol);
await unitOfWork.CommitAsync();
Assert.Single(ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories));
Assert.NotNull(await unitOfWork.UserRepository.GetBookmarkAsync(1));
}
[Fact]
public async Task ShouldNotDeleteBookmark_OnVolumeDeletion()
{
var filesystem = CreateFileSystem();
filesystem.AddFile($"{CacheDirectory}1/0001.jpg", new MockFileData("123"));
filesystem.AddFile($"{BookmarkDirectory}1/1/0001.jpg", new MockFileData("123"));
var (unitOfWork, context, _) = await CreateDatabase();
var series = new SeriesBuilder("Test")
.WithFormat(MangaFormat.Epub)
.WithVolume(new VolumeBuilder("1")
.WithMinNumber(1)
.WithChapter(new ChapterBuilder("1")
.Build())
.Build())
.Build();
series.Library = new LibraryBuilder("Test LIb").Build();
context.Series.Add(series);
context.AppUser.Add(new AppUser()
{
UserName = "Joe",
Bookmarks = new List<AppUserBookmark>()
{
new AppUserBookmark()
{
Page = 1,
ChapterId = 1,
FileName = $"1/1/0001.jpg",
SeriesId = 1,
VolumeId = 1
}
}
});
await context.SaveChangesAsync();
var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Bookmarks);
Assert.NotEmpty(user!.Bookmarks);
series.Volumes = new List<Volume>();
unitOfWork.SeriesRepository.Update(series);
await unitOfWork.CommitAsync();
var ds = new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem);
Assert.Single(ds.GetFiles(BookmarkDirectory, searchOption:SearchOption.AllDirectories));
Assert.NotNull(await unitOfWork.UserRepository.GetBookmarkAsync(1));
}
#endregion
}

View File

@ -41,7 +41,8 @@ public class CleanupServiceTests(ITestOutputHelper outputHelper): AbstractDbTest
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>(),
Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()),
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>());
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
return Task.FromResult<(ILogger<CleanupService>, IEventHub, IReaderService)>((logger, messageHub, readerService));
}

View File

@ -10,6 +10,7 @@ using API.Entities;
using API.Entities.Enums;
using API.Helpers.Builders;
using API.Services;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner;
using AutoMapper;
using Kavita.Common;
@ -740,7 +741,7 @@ public class OidcServiceTests(ITestOutputHelper outputHelper): AbstractDbTest(ou
var accountService = new AccountService(userManager, Substitute.For<ILogger<AccountService>>(),
unitOfWork, mapper, Substitute.For<ILocalizationService>());
var oidcService = new OidcService(Substitute.For<ILogger<OidcService>>(), userManager, unitOfWork,
accountService, Substitute.For<IEmailService>());
accountService, Substitute.For<IEmailService>(), Substitute.For<ICoverDbService>());
return (oidcService, user, accountService, userManager);
}

View File

@ -44,7 +44,7 @@ public class OpdsServiceTests(ITestOutputHelper testOutputHelper) : AbstractDbTe
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(), ds,
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>());
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
var localizationService =
new LocalizationService(ds, new MockHostingEnvironment(), Substitute.For<IMemoryCache>(), unitOfWork);

View File

@ -33,7 +33,8 @@ public class ReaderServiceTests(ITestOutputHelper testOutputHelper) : AbstractDb
return new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()),
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>());
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
}
#region FormatBookmarkFolderPath

View File

@ -35,7 +35,8 @@ public class ReadingListServiceTests(ITestOutputHelper outputHelper): AbstractDb
var readerService = new ReaderService(unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()),
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(), Substitute.For<IClientInfoAccessor>());
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
return (readingListService, readerService);
}

View File

@ -59,7 +59,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>()); // Do not use the actual one
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>()); // Do not use the actual one
var hookedUpReaderService = new ReaderService(unitOfWork,
Substitute.For<ILogger<ReaderService>>(),
@ -67,7 +67,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
Substitute.For<IImageService>(),
Substitute.For<IDirectoryService>(),
service, Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>());
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
await SeedData(unitOfWork, context);
@ -124,11 +124,11 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
await unitOfWork.CommitAsync();
}
private async Task<ScrobbleEvent> CreateScrobbleEvent(int? seriesId = null)
private async Task<ScrobbleEvent> CreateScrobbleEvent(IUnitOfWork unitOfWork, int? seriesId = null)
{
var (unitOfWork, context, _) = await CreateDatabase();
await Setup(unitOfWork, context);
// var (unitOfWork, context, _) = await CreateDatabase();
// await Setup(unitOfWork, context);
//
var evt = new ScrobbleEvent
{
@ -163,7 +163,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
ErrorMessage = "Unauthorized"
});
var evt = await CreateScrobbleEvent();
var evt = await CreateScrobbleEvent(unitOfWork);
await Assert.ThrowsAsync<KavitaException>(async () =>
{
await service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
@ -184,9 +184,9 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
ErrorMessage = "Unknown Series"
});
var evt = await CreateScrobbleEvent(1);
var evt = await CreateScrobbleEvent(unitOfWork, 1);
await service.PostScrobbleUpdate(new ScrobbleDto(), "", evt);
await service.PostScrobbleUpdate(new ScrobbleDto(), string.Empty, evt);
await unitOfWork.CommitAsync();
Assert.True(evt.IsErrored);
@ -212,7 +212,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
ErrorMessage = "Access token is invalid"
});
var evt = await CreateScrobbleEvent();
var evt = await CreateScrobbleEvent(unitOfWork);
await Assert.ThrowsAsync<KavitaException>(async () =>
{
@ -249,7 +249,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(4);
Assert.NotNull(chapter);
var volume = await unitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
// Call Scrobble without having any progress
@ -280,7 +280,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(4);
Assert.NotNull(chapter);
var volume = await unitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
// Mark something as read to trigger event creation
@ -335,7 +335,7 @@ public class ScrobblingServiceTests(ITestOutputHelper outputHelper): AbstractDbT
var user = await unitOfWork.UserRepository.GetUserByIdAsync(1);
Assert.NotNull(user);
var volume = await unitOfWork.VolumeRepository.GetVolumeAsync(1, VolumeIncludes.Chapters);
var volume = await unitOfWork.VolumeRepository.GetVolumeByIdAsync(1, VolumeIncludes.Chapters);
Assert.NotNull(volume);
await readerService.MarkChaptersAsRead(user, 1, new List<Chapter>() {volume.Chapters[0]});

View File

@ -28,7 +28,7 @@ public class TachiyomiServiceTests(ITestOutputHelper outputHelper): AbstractDbTe
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()),
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>());
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
var tachiyomiService = new TachiyomiService(unitOfWork, mapper, Substitute.For<ILogger<TachiyomiService>>(), readerService);
return (readerService, tachiyomiService);

View File

@ -34,7 +34,7 @@ public class WordCountAnalysisTests(ITestOutputHelper outputHelper): AbstractDbT
Substitute.For<IEventHub>(), Substitute.For<IImageService>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()),
Substitute.For<IScrobblingService>(), Substitute.For<IReadingSessionService>(),
Substitute.For<IClientInfoAccessor>());
Substitute.For<IClientInfoAccessor>(), Substitute.For<ISeriesService>(), Substitute.For<IEntityDisplayService>());
}
[Fact]

View File

@ -52,6 +52,8 @@
<!-- Override vulnerable transitive dependencies -->
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.1" />
<PackageReference Include="NeoSmart.Caching.Sqlite.AspNetCore" Version="9.0.1" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
</ItemGroup>

View File

@ -20,6 +20,7 @@ using API.Helpers;
using API.Helpers.Builders;
using API.Middleware;
using API.Services;
using API.Services.Caching;
using API.SignalR;
using AutoMapper;
using Hangfire;
@ -56,6 +57,7 @@ public class AccountController : BaseApiController
private readonly IEventHub _eventHub;
private readonly ILocalizationService _localizationService;
private readonly IAuthenticationSchemeProvider _authenticationSchemeProvider;
private readonly IAuthKeyCacheInvalidator _authKeyCacheInvalidator;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
@ -65,7 +67,8 @@ public class AccountController : BaseApiController
IMapper mapper, IAccountService accountService,
IEmailService emailService, IEventHub eventHub,
ILocalizationService localizationService,
IAuthenticationSchemeProvider authenticationSchemeProvider)
IAuthenticationSchemeProvider authenticationSchemeProvider,
IAuthKeyCacheInvalidator authKeyCacheInvalidator)
{
_userManager = userManager;
_signInManager = signInManager;
@ -78,6 +81,7 @@ public class AccountController : BaseApiController
_eventHub = eventHub;
_localizationService = localizationService;
_authenticationSchemeProvider = authenticationSchemeProvider;
_authKeyCacheInvalidator = authKeyCacheInvalidator;
}
/// <summary>
@ -717,6 +721,10 @@ public class AccountController : BaseApiController
{
roles.Add(PolicyConstants.PlebRole);
}
else
{
roles.Remove(PolicyConstants.ReadOnlyRole);
}
foreach (var role in roles)
{
@ -1190,17 +1198,26 @@ public class AccountController : BaseApiController
var authKey = await _unitOfWork.UserRepository.GetAuthKeyById(authKeyId);
if (authKey?.AppUserId != UserId) return BadRequest();
var oldKeyValue = authKey.Key;
// Get original expiresAt - createdAt for offset to reset expiresAt
if (authKey.ExpiresAtUtc != null)
{
var originalDuration = authKey.ExpiresAtUtc.Value - authKey.CreatedAtUtc;
authKey.ExpiresAtUtc = DateTime.UtcNow.Add(originalDuration);
}
authKey.Key = AuthKeyHelper.GenerateKey(dto.KeyLength);
await _unitOfWork.CommitAsync();
return Ok(_mapper.Map<AuthKeyDto>(authKey));
await _authKeyCacheInvalidator.InvalidateAsync(oldKeyValue);
var newDto = _mapper.Map<AuthKeyDto>(authKey);
await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyUpdatedEvent(newDto), UserId);
return Ok(newDto);
}
/// <summary>
@ -1212,12 +1229,6 @@ public class AccountController : BaseApiController
[DisallowRole(PolicyConstants.ReadOnlyRole)]
public async Task<ActionResult<AuthKeyDto>> CreateAuthKey(RotateAuthKeyRequestDto dto)
{
// Upper bound check might not be needed, it doesn't *realy* matter if users have bigger keys
if (string.IsNullOrEmpty(dto.Name) || dto.KeyLength < 8 || dto.KeyLength > 32)
{
return BadRequest();
}
// Validate the name doesn't collide
var authKeys = await _unitOfWork.UserRepository.GetAuthKeysForUserId(UserId);
if (authKeys.Any(k => string.Equals(k.Name, dto.Name, StringComparison.InvariantCultureIgnoreCase)))
@ -1237,7 +1248,11 @@ public class AccountController : BaseApiController
_unitOfWork.UserRepository.Add(newKey);
await _unitOfWork.CommitAsync();
return Ok(_mapper.Map<AuthKeyDto>(newKey));
var newDto = _mapper.Map<AuthKeyDto>(newKey);
await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyUpdatedEvent(newDto), UserId);
return Ok(newDto);
}
/// <summary>
@ -1255,6 +1270,9 @@ public class AccountController : BaseApiController
_unitOfWork.UserRepository.Delete(authKey);
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageToAsync(MessageFactory.AuthKeyUpdate, MessageFactory.AuthKeyDeletedEvent(authKeyId), UserId);
return Ok();
}
}

View File

@ -1,4 +1,6 @@
using API.Services.Store;
using API.Middleware;
using API.Services.Store;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.DependencyInjection;
@ -7,18 +9,16 @@ namespace API.Controllers;
#nullable enable
[Authorize]
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class BaseApiController : ControllerBase
{
private IUserContext? _userContext;
/// <summary>
/// Gets the current user context. Available in all derived controllers.
/// </summary>
protected IUserContext UserContext =>
_userContext ??= HttpContext.RequestServices.GetRequiredService<IUserContext>();
field ??= HttpContext.RequestServices.GetRequiredService<IUserContext>();
/// <summary>
/// Gets the current authenticated user's ID.
@ -31,4 +31,5 @@ public class BaseApiController : ControllerBase
/// </summary>
/// <remarks>Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username</remarks>
protected string? Username => UserContext.GetUsername();
}

View File

@ -67,7 +67,7 @@ public class ChapterController : BaseApiController
if (chapter == null)
return BadRequest(_localizationService.Translate(UserId, "chapter-doesnt-exist"));
var vol = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId, VolumeIncludes.Chapters);
var vol = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId, VolumeIncludes.Chapters);
if (vol == null) return BadRequest(_localizationService.Translate(UserId, "volume-doesnt-exist"));
// If there is only 1 chapter within the volume, then we need to remove the volume
@ -139,7 +139,7 @@ public class ChapterController : BaseApiController
var chaptersToDelete = volumeGroup.ToList();
// Fetch the volume
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
return BadRequest(_localizationService.Translate(UserId, "volume-doesnt-exist"));

View File

@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.Uploads;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using TaskScheduler = API.Services.TaskScheduler;
namespace API.Controllers;
/// <summary>
/// All APIs here are subject to be removed and are no longer maintained
/// </summary>
[Route("api/")]
public class DeprecatedController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILocalizationService _localizationService;
private readonly ITaskScheduler _taskScheduler;
private readonly ILogger<DeprecatedController> _logger;
public DeprecatedController(IUnitOfWork unitOfWork, ILocalizationService localizationService, ITaskScheduler taskScheduler, ILogger<DeprecatedController> logger)
{
_unitOfWork = unitOfWork;
_localizationService = localizationService;
_taskScheduler = taskScheduler;
_logger = logger;
}
/// <summary>
/// Return all Series that are in the current logged-in user's Want to Read list, filtered (deprecated, use v2)
/// </summary>
/// <remarks>This will be removed in v0.9.0</remarks>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("want-to-read")]
[Obsolete("use v2 instead. This will be removed in v0.9.0")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams? userParams, FilterDto filterDto)
{
userParams ??= new UserParams();
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(UserId, userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(UserId, pagedList);
return Ok(pagedList);
}
/// <summary>
/// All chapter entities will load this data by default. Will not be maintained as of v0.8.1
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[Obsolete("All chapter entities will load this data by default. Will be removed in v0.9.0")]
[HttpGet("series/chapter-metadata")]
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)
{
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
}
/// <summary>
/// Gets series with the applied Filter
/// </summary>
/// <remarks>This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2</remarks>
/// <param name="libraryId"></param>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost("series")]
[Obsolete("use v2. Will be removed in v0.9.0")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var userId = UserId;
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Gets all recently added series. Obsolete, use recently-added-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[HttpPost("series/recently-added")]
[Obsolete("use recently-added-v2. Will be removed in v0.9.0")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = UserId;
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Returns all series for the library. Obsolete, use all-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("series/all")]
[Obsolete("Use all-v2. Will be removed in v0.9.0")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = UserId;
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>
/// <param name="uploadFileDto">Does not use Url property</param>
/// <returns></returns>
[Authorize(Policy = PolicyGroups.AdminPolicy)]
[HttpPost("upload/reset-chapter-lock")]
[Obsolete("Use LockCover in UploadFileDto, will be removed in v0.9.0")]
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
{
try
{
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
if (chapter == null) return BadRequest(await _localizationService.Translate(UserId, "chapter-doesnt-exist"));
var originalFile = chapter.CoverImage;
chapter.CoverImage = string.Empty;
chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId))!;
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!;
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
if (originalFile != null) System.IO.File.Delete(originalFile);
await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(UserId, "reset-chapter-lock"));
}
}

View File

@ -23,9 +23,7 @@ namespace API.Controllers;
#nullable enable
public class MetadataController(IUnitOfWork unitOfWork, ILocalizationService localizationService,
IExternalMetadataService metadataService)
: BaseApiController
public class MetadataController(IUnitOfWork unitOfWork, IExternalMetadataService metadataService) : BaseApiController
{
public const string CacheKey = "kavitaPlusSeriesDetail_";

View File

@ -21,7 +21,7 @@ using MimeTypes;
namespace API.Controllers;
#nullable enable
[AllowAnonymous]
[Authorize]
public class OpdsController : BaseApiController
{
private readonly IOpdsService _opdsService;

View File

@ -1,6 +1,7 @@
#nullable enable
using System.Threading.Tasks;
using API.Extensions;
using API.Middleware;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
@ -16,6 +17,7 @@ namespace API.Controllers;
[Route("[controller]")]
public class OidcController(ILogger<OidcController> logger, [FromServices] ConfigurationManager<OpenIdConnectConfiguration>? configurationManager = null): ControllerBase
{
[SkipDeviceTracking]
[AllowAnonymous]
[HttpGet("login")]
public IActionResult Login(string returnUrl = "/")
@ -29,6 +31,7 @@ public class OidcController(ILogger<OidcController> logger, [FromServices] Confi
return Challenge(properties, IdentityServiceExtensions.OpenIdConnect);
}
[SkipDeviceTracking]
[HttpGet("logout")]
public async Task<IActionResult> Logout()
{

View File

@ -1,8 +1,10 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Metadata;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Metadata.Browse;
@ -14,6 +16,7 @@ using API.Helpers;
using API.Services;
using API.Services.Plus;
using API.Services.Tasks.Metadata;
using API.Services.Tasks.Scanner.Parser;
using API.SignalR;
using AutoMapper;
using Microsoft.AspNetCore.Authorization;
@ -177,8 +180,7 @@ public class PersonController : BaseApiController
}
var asin = dto.Asin?.Trim();
if (!string.IsNullOrEmpty(asin) &&
(ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin)))
if (!string.IsNullOrEmpty(asin) && Parser.IsLikelyValidAsin(asin))
{
person.Asin = asin;
}
@ -189,18 +191,6 @@ public class PersonController : BaseApiController
return Ok(_mapper.Map<PersonDto>(person));
}
/// <summary>
/// Validates if the ASIN (10/13) is valid
/// </summary>
/// <param name="asin"></param>
/// <returns></returns>
[HttpGet("valid-asin")]
public ActionResult<bool> ValidateAsin(string asin)
{
return Ok(!string.IsNullOrEmpty(asin) &&
(ArticleNumberHelper.IsValidIsbn10(asin) || ArticleNumberHelper.IsValidIsbn13(asin)));
}
/// <summary>
/// Attempts to download the cover from CoversDB (Note: Not yet release in Kavita)
/// </summary>

View File

@ -74,4 +74,15 @@ public class PluginController(IUnitOfWork unitOfWork, ITokenService tokenService
if (userId <= 0) throw new KavitaUnauthenticatedUserException();
return Ok((await unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallVersion)).Value);
}
/// <summary>
/// Returns the expiration (UTC) of the authenticated Auth key (or null if none set)
/// </summary>
/// <remarks>Will always return null if the Auth Key does not belong to this account</remarks>
/// <returns></returns>
[HttpGet("authkey-expires")]
public async Task<ActionResult<DateTime?>> GetAuthKeyExpiration([Required] string authKey)
{
return Ok(await unitOfWork.UserRepository.GetAuthKeyExpiration(authKey, UserId));
}
}

View File

@ -1023,4 +1023,41 @@ public class ReaderController : BaseApiController
}
/// <summary>
/// Check if we should prompt the user for rereads for the given series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("prompt-reread/series")]
public async Task<ActionResult<RereadDto>> ShouldPromptForSeriesReRead(int seriesId)
{
return Ok(await _readerService.CheckSeriesForReRead(UserId, seriesId));
}
/// <summary>
/// Check if we should prompt the user for rereads for the given volume
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("prompt-reread/volume")]
public async Task<ActionResult<RereadDto>> ShouldPromptForVolumeReRead(int libraryId, int seriesId, int volumeId)
{
return Ok(await _readerService.CheckVolumeForReRead(UserId, volumeId, seriesId, libraryId));
}
/// <summary>
/// Check if we should prompt the user for rereads for the given chapter
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("prompt-reread/chapter")]
public async Task<ActionResult<RereadDto>> ShouldPromptForChapterReRead(int libraryId, int seriesId, int chapterId)
{
return Ok(await _readerService.CheckChapterForReRead(UserId, chapterId, seriesId, libraryId));
}
}

View File

@ -1,5 +1,6 @@
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
@ -60,15 +61,12 @@ public class SearchController : BaseApiController
{
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(Username!);
if (user == null) return Unauthorized();
var libraries = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search);
var libraries = await _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(UserId, QueryContext.Search);
if (libraries.Count == 0) return BadRequest(await _localizationService.Translate(UserId, "libraries-restricted"));
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var isAdmin = UserContext.HasRole(PolicyConstants.AdminRole);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
var series = await _unitOfWork.SeriesRepository.SearchSeries(UserId, isAdmin,
libraries, queryString, includeChapterAndFiles);
return Ok(series);

View File

@ -68,32 +68,6 @@ public class SeriesController : BaseApiController
_matchSeriesCacheProvider = cachingProviderFactory.GetCachingProvider(EasyCacheProfiles.KavitaPlusMatchSeries);
}
/// <summary>
/// Gets series with the applied Filter
/// </summary>
/// <remarks>This is considered v1 and no longer used by Kavita, but will be supported for sometime. See series/v2</remarks>
/// <param name="libraryId"></param>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost]
[Obsolete("use v2")]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var userId = UserId;
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Gets series with the applied Filter
/// </summary>
@ -183,19 +157,9 @@ public class SeriesController : BaseApiController
{
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, UserId);
if (chapter == null) return NoContent();
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(UserId, chapter));
}
await _unitOfWork.ChapterRepository.AddChapterModifiers(UserId, chapter);
/// <summary>
/// All chapter entities will load this data by default. Will not be maintained as of v0.8.1
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[Obsolete("All chapter entities will load this data by default. Will not be maintained as of v0.8.1")]
[HttpGet("chapter-metadata")]
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)
{
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
return Ok(chapter);
}
/// <summary>
@ -252,32 +216,6 @@ public class SeriesController : BaseApiController
return Ok();
}
/// <summary>
/// Gets all recently added series. Obsolete, use recently-added-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Instant")]
[HttpPost("recently-added")]
[Obsolete("use recently-added-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = UserId;
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Gets all recently added series
/// </summary>
@ -338,30 +276,6 @@ public class SeriesController : BaseApiController
return Ok(series);
}
/// <summary>
/// Returns all series for the library. Obsolete, use all-v2
/// </summary>
/// <param name="filterDto"></param>
/// <param name="userParams"></param>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpPost("all")]
[Obsolete("Use all-v2")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeries(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var userId = UserId;
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, userId, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest(await _localizationService.Translate(UserId, "no-series"));
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
/// <summary>
/// Fetches series that are on deck aka have progress on them.

View File

@ -35,16 +35,7 @@ public class StatsController(
IDirectoryService directoryService)
: BaseApiController
{
[HttpGet("user/{userId}/read")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)]
public async Task<ActionResult<UserReadStatistics>> GetUserReadStatistics(int userId)
{
var user = await unitOfWork.UserRepository.GetUserByUsernameAsync(Username!);
if (user!.Id != userId && !await userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
return Unauthorized(await localizationService.Translate(UserId, "stats-permission-denied"));
return Ok(await statService.GetUserReadStatistics(userId, new List<int>()));
}
[Authorize(PolicyGroups.AdminPolicy)]
[HttpGet("server/stats")]
@ -404,6 +395,14 @@ public class StatsController(
return Ok(await statService.GetUserStatBar(filter, userId, UserId));
}
[ProfilePrivacy]
[HttpGet("user-read")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Statistics)]
public async Task<ActionResult<UserReadStatistics>> GetUserReadStatistics(int userId)
{
return Ok(await statService.GetUserReadStatistics(userId, []));
}
// TODO: Can we cache this? Can we make an attribute to cache methods based on keys?
/// <summary>
/// Cleans the stats filter to only include valid data. I.e. only requests libraries the user has access to

View File

@ -286,7 +286,7 @@ public class UploadController : BaseApiController
chapter.CoverImageLocked = lockState;
chapter.KPlusOverrides.Remove(MetadataSettingField.ChapterCovers);
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(chapter.VolumeId);
if (volume != null)
{
volume.CoverImage = chapter.CoverImage;
@ -338,7 +338,7 @@ public class UploadController : BaseApiController
// See if we can do this all in memory without touching underlying system
try
{
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(uploadFileDto.Id, VolumeIncludes.Chapters);
var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(uploadFileDto.Id, VolumeIncludes.Chapters);
if (volume == null) return BadRequest(await _localizationService.Translate(UserId, "volume-doesnt-exist"));
var filePath = string.Empty;
@ -444,49 +444,6 @@ public class UploadController : BaseApiController
return BadRequest(await _localizationService.Translate(UserId, "generic-cover-library-save"));
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>
/// <param name="uploadFileDto">Does not use Url property</param>
/// <returns></returns>
[Authorize(Policy = PolicyGroups.AdminPolicy)]
[HttpPost("reset-chapter-lock")]
[Obsolete("Use LockCover in UploadFileDto, will be removed in v0.9.0")]
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
{
try
{
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id);
if (chapter == null) return BadRequest(await _localizationService.Translate(UserId, "chapter-doesnt-exist"));
var originalFile = chapter.CoverImage;
chapter.CoverImage = string.Empty;
chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = (await _unitOfWork.VolumeRepository.GetVolumeAsync(chapter.VolumeId))!;
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!;
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
if (originalFile != null) System.IO.File.Delete(originalFile);
await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true);
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest(await _localizationService.Translate(UserId, "reset-chapter-lock"));
}
/// <summary>
/// Replaces person tag cover image and locks it with a base64 encoded image
@ -522,9 +479,9 @@ public class UploadController : BaseApiController
/// <remarks>You MUST be the user in question</remarks>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = PolicyGroups.AdminPolicy)]
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
[HttpPost("user")]
[DisallowRole(PolicyConstants.ReadOnlyRole)]
[RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)]
public async Task<ActionResult> UploadUserCoverImageFromUrl(UploadFileDto uploadFileDto)
{
try

View File

@ -39,7 +39,7 @@ public class VolumeController : BaseApiController
[HttpDelete]
public async Task<ActionResult<bool>> DeleteVolume(int volumeId)
{
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId,
var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId,
VolumeIncludes.Chapters | VolumeIncludes.People | VolumeIncludes.Tags);
if (volume == null)
return BadRequest(_localizationService.Translate(UserId, "volume-doesnt-exist"));

View File

@ -38,26 +38,6 @@ public class WantToReadController : BaseApiController
_localizationService = localizationService;
}
/// <summary>
/// Return all Series that are in the current logged-in user's Want to Read list, filtered (deprecated, use v2)
/// </summary>
/// <remarks>This will be removed in v0.9.0</remarks>
/// <param name="userParams"></param>
/// <param name="filterDto"></param>
/// <returns></returns>
[HttpPost]
[Obsolete("use v2 instead. This will be removed in v0.9.0")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams? userParams, FilterDto filterDto)
{
userParams ??= new UserParams();
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(UserId, userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(UserId, pagedList);
return Ok(pagedList);
}
/// <summary>
/// Return all Series that are in the current logged in user's Want to Read list, filtered
/// </summary>

View File

@ -1,6 +1,4 @@
using System;
using System.ComponentModel.DataAnnotations;
using API.Helpers;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs.Account;
#nullable enable
@ -8,8 +6,10 @@ namespace API.DTOs.Account;
public sealed record RotateAuthKeyRequestDto
{
[Required]
[Range(8, 32)]
public int KeyLength { get; set; }
[Required(AllowEmptyStrings = false)]
public required string Name { get; set; }
public string? ExpiresUtc { get; set; }
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using API.DTOs.Metadata;
using API.DTOs.Person;
using API.Entities.Enums;
@ -173,6 +174,8 @@ public class ChapterDto : IHasReadTimeEstimate, IHasCoverImage
/// <inheritdoc cref="API.Entities.Chapter.SecondaryColor"/>
public string? SecondaryColor { get; set; } = string.Empty;
public MangaFormat? Format => Files.FirstOrDefault()?.Format;
public void ResetColorScape()
{
PrimaryColor = string.Empty;

View File

@ -0,0 +1,37 @@
using API.Entities.Enums;
namespace API.DTOs.Reader;
public sealed record RereadDto
{
/// <summary>
/// Should the prompt be shown
/// </summary>
public required bool ShouldPrompt { get; init; }
/// <summary>
/// If the prompt is triggered because of time, false when triggered because of fully read
/// </summary>
public bool TimePrompt { get; init; } = false;
/// <summary>
/// Days elapsed since <see cref="ChapterOnReread"/> was last read
/// </summary>
public int DaysSinceLastRead { get; init; }
/// <summary>
/// The chapter to open if continue is selected
/// </summary>
public RereadChapterDto ChapterOnContinue { get; init; }
/// <summary>
/// The chapter to open if reread is selected, this may be equal to <see cref="ChapterOnContinue"/>
/// </summary>
public RereadChapterDto ChapterOnReread { get; init; }
public static RereadDto Dont()
{
return new RereadDto
{
ShouldPrompt = false
};
}
}
public sealed record RereadChapterDto(int LibraryId, int SeriesId, int ChapterId, string Label, MangaFormat? Format);

View File

@ -19,7 +19,10 @@ public sealed record UserReadStatistics
/// </summary>
public long TimeSpentReading { get; set; }
public long ChaptersRead { get; set; }
public DateTime LastActive { get; set; }
/// <summary>
/// Last time user read anything
/// </summary>
public DateTime? LastActiveUtc { get; set; }
public double AvgHoursPerWeekSpentReading { get; set; }
public IEnumerable<StatCount<float>>? PercentReadPerLibrary { get; set; }

View File

@ -103,18 +103,6 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
base.OnModelCreating(builder);
builder.Entity<AppUser>()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.User)
.HasForeignKey(ur => ur.UserId)
.IsRequired();
builder.Entity<AppRole>()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.Role)
.HasForeignKey(ur => ur.RoleId)
.IsRequired();
builder.Entity<SeriesRelation>()
.HasOne(pt => pt.Series)
.WithMany(p => p.Relations)
@ -130,6 +118,79 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<ExternalSeriesMetadata>()
.HasOne(em => em.Series)
.WithOne(s => s.ExternalSeriesMetadata)
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<AppUserCollection>()
.Property(b => b.AgeRating)
.HasDefaultValue(AgeRating.Unknown);
#region Library
builder.Entity<Library>()
.Property(b => b.AllowScrobbling)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.AllowMetadataMatching)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.EnableMetadata)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(l => l.DefaultLanguage)
.HasDefaultValue(string.Empty);
#endregion
#region Chapter
builder.Entity<Chapter>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
builder.Entity<Chapter>()
.Property(b => b.ISBN)
.HasDefaultValue(string.Empty);
// Configure the many-to-many relationship for Chapter and Person
builder.Entity<ChapterPeople>()
.HasKey(cp => new { cp.ChapterId, cp.PersonId, cp.Role });
builder.Entity<ChapterPeople>()
.HasOne(cp => cp.Chapter)
.WithMany(c => c.People)
.HasForeignKey(cp => cp.ChapterId);
builder.Entity<ChapterPeople>()
.HasOne(cp => cp.Person)
.WithMany(p => p.ChapterPeople)
.HasForeignKey(cp => cp.PersonId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<Chapter>()
.Property(sm => sm.KPlusOverrides)
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
#endregion
#region User & Preferences
builder.Entity<AppUser>()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.User)
.HasForeignKey(ur => ur.UserId)
.IsRequired();
builder.Entity<AppRole>()
.HasMany(ur => ur.UserRoles)
.WithOne(u => u.Role)
.HasForeignKey(ur => ur.RoleId)
.IsRequired();
builder.Entity<AppUserPreferences>()
.Property(b => b.BookThemeName)
.HasDefaultValue("Dark");
@ -162,150 +223,6 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.Property(b => b.PromptForRereadsAfter)
.HasDefaultValue(30);
builder.Entity<Library>()
.Property(b => b.AllowScrobbling)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.AllowMetadataMatching)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.EnableMetadata)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(l => l.DefaultLanguage)
.HasDefaultValue(string.Empty);
builder.Entity<Chapter>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
builder.Entity<SeriesMetadata>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
builder.Entity<Chapter>()
.Property(b => b.ISBN)
.HasDefaultValue(string.Empty);
builder.Entity<AppUserDashboardStream>()
.Property(b => b.StreamType)
.HasDefaultValue(DashboardStreamType.SmartFilter);
builder.Entity<AppUserDashboardStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
builder.Entity<AppUserSideNavStream>()
.Property(b => b.StreamType)
.HasDefaultValue(SideNavStreamType.SmartFilter);
builder.Entity<AppUserSideNavStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
builder.Entity<ExternalSeriesMetadata>()
.HasOne(em => em.Series)
.WithOne(s => s.ExternalSeriesMetadata)
.HasForeignKey<ExternalSeriesMetadata>(em => em.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<AppUserCollection>()
.Property(b => b.AgeRating)
.HasDefaultValue(AgeRating.Unknown);
// Configure the many-to-many relationship for Movie and Person
builder.Entity<ChapterPeople>()
.HasKey(cp => new { cp.ChapterId, cp.PersonId, cp.Role });
builder.Entity<ChapterPeople>()
.HasOne(cp => cp.Chapter)
.WithMany(c => c.People)
.HasForeignKey(cp => cp.ChapterId);
builder.Entity<ChapterPeople>()
.HasOne(cp => cp.Person)
.WithMany(p => p.ChapterPeople)
.HasForeignKey(cp => cp.PersonId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<SeriesMetadataPeople>()
.HasKey(smp => new { smp.SeriesMetadataId, smp.PersonId, smp.Role });
builder.Entity<SeriesMetadataPeople>()
.HasOne(smp => smp.SeriesMetadata)
.WithMany(sm => sm.People)
.HasForeignKey(smp => smp.SeriesMetadataId);
builder.Entity<SeriesMetadataPeople>()
.HasOne(smp => smp.Person)
.WithMany(p => p.SeriesMetadataPeople)
.HasForeignKey(smp => smp.PersonId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<SeriesMetadataPeople>()
.Property(b => b.OrderWeight)
.HasDefaultValue(0);
builder.Entity<MetadataSettings>()
.Property(x => x.AgeRatingMappings)
.HasJsonConversion([]);
// Ensure blacklist is stored as a JSON array
builder.Entity<MetadataSettings>()
.Property(x => x.Blacklist)
.HasJsonConversion([]);
builder.Entity<MetadataSettings>()
.Property(x => x.Whitelist)
.HasJsonConversion([]);
builder.Entity<MetadataSettings>()
.Property(x => x.Overrides)
.HasJsonConversion([]);
// Configure one-to-many relationship
builder.Entity<MetadataSettings>()
.HasMany(x => x.FieldMappings)
.WithOne(x => x.MetadataSettings)
.HasForeignKey(x => x.MetadataSettingsId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<MetadataSettings>()
.Property(b => b.Enabled)
.HasDefaultValue(true);
builder.Entity<MetadataSettings>()
.Property(b => b.EnableCoverImage)
.HasDefaultValue(true);
builder.Entity<AppUserReadingProfile>()
.Property(b => b.BookThemeName)
.HasDefaultValue("Dark");
builder.Entity<AppUserReadingProfile>()
.Property(b => b.BackgroundColor)
.HasDefaultValue("#000000");
builder.Entity<AppUserReadingProfile>()
.Property(b => b.BookReaderWritingStyle)
.HasDefaultValue(WritingStyle.Horizontal);
builder.Entity<AppUserReadingProfile>()
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
.HasDefaultValue(true);
builder.Entity<AppUserReadingProfile>()
.Property(rp => rp.LibraryIds)
.HasJsonConversion([])
.HasColumnType("TEXT");
builder.Entity<AppUserReadingProfile>()
.Property(rp => rp.SeriesIds)
.HasJsonConversion([])
.HasColumnType("TEXT");
builder.Entity<SeriesMetadata>()
.Property(sm => sm.KPlusOverrides)
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<Chapter>()
.Property(sm => sm.KPlusOverrides)
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<AppUserPreferences>()
.Property(a => a.BookReaderHighlightSlots)
.HasJsonConversion([])
@ -333,11 +250,60 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasJsonConversion(new AppUserOpdsPreferences())
.HasColumnType("TEXT")
.HasDefaultValue(new AppUserOpdsPreferences());
#endregion
#region AppUserReadingProfile
builder.Entity<AppUserReadingProfile>()
.Property(b => b.BookThemeName)
.HasDefaultValue("Dark");
builder.Entity<AppUserReadingProfile>()
.Property(b => b.BackgroundColor)
.HasDefaultValue("#000000");
builder.Entity<AppUserReadingProfile>()
.Property(b => b.BookReaderWritingStyle)
.HasDefaultValue(WritingStyle.Horizontal);
builder.Entity<AppUserReadingProfile>()
.Property(b => b.AllowAutomaticWebtoonReaderDetection)
.HasDefaultValue(true);
builder.Entity<AppUserReadingProfile>()
.Property(rp => rp.LibraryIds)
.HasJsonConversion([])
.HasColumnType("TEXT");
builder.Entity<AppUserReadingProfile>()
.Property(rp => rp.SeriesIds)
.HasJsonConversion([])
.HasColumnType("TEXT");
#endregion
#region AppUser Streams
builder.Entity<AppUserDashboardStream>()
.Property(b => b.StreamType)
.HasDefaultValue(DashboardStreamType.SmartFilter);
builder.Entity<AppUserDashboardStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
builder.Entity<AppUserSideNavStream>()
.Property(b => b.StreamType)
.HasDefaultValue(SideNavStreamType.SmartFilter);
builder.Entity<AppUserSideNavStream>()
.HasIndex(e => e.Visible)
.IsUnique(false);
#endregion
#region Annoations
builder.Entity<AppUserAnnotation>()
.PrimitiveCollection(a => a.Likes)
.HasDefaultValue(new List<int>());
#endregion
#region Reading Sessions & History
builder.Entity<AppUserReadingSession>()
.Property(b => b.IsActive)
.HasDefaultValue(true);
@ -361,7 +327,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<ClientInfoData>());
#endregion
#region Client Device
builder.Entity<ClientDevice>()
.Property(sm => sm.CurrentClientInfo)
.HasJsonConversion(new ClientInfoData())
@ -373,6 +341,9 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasJsonConversion(new ClientInfoData())
.HasColumnType("TEXT")
.HasDefaultValue(new ClientInfoData());
#endregion
#region SeriesMetadata
builder.Entity<SeriesMetadata>()
.HasMany(sm => sm.Tags)
@ -384,9 +355,138 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.WithMany(t => t.SeriesMetadatas)
.UsingEntity<GenreSeriesMetadata>();
builder.Entity<SeriesMetadata>()
.Property(sm => sm.KPlusOverrides)
.HasJsonConversion([])
.HasColumnType("TEXT")
.HasDefaultValue(new List<MetadataSettingField>());
builder.Entity<SeriesMetadataPeople>()
.HasKey(smp => new { smp.SeriesMetadataId, smp.PersonId, smp.Role });
builder.Entity<SeriesMetadataPeople>()
.HasOne(smp => smp.SeriesMetadata)
.WithMany(sm => sm.People)
.HasForeignKey(smp => smp.SeriesMetadataId);
builder.Entity<SeriesMetadataPeople>()
.HasOne(smp => smp.Person)
.WithMany(p => p.SeriesMetadataPeople)
.HasForeignKey(smp => smp.PersonId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<SeriesMetadataPeople>()
.Property(b => b.OrderWeight)
.HasDefaultValue(0);
builder.Entity<MetadataSettings>()
.Property(x => x.AgeRatingMappings)
.HasJsonConversion([]);
builder.Entity<SeriesMetadata>()
.Property(b => b.WebLinks)
.HasDefaultValue(string.Empty);
// Ensure blacklist is stored as a JSON array
builder.Entity<MetadataSettings>()
.Property(x => x.Blacklist)
.HasJsonConversion([]);
builder.Entity<MetadataSettings>()
.Property(x => x.Whitelist)
.HasJsonConversion([]);
builder.Entity<MetadataSettings>()
.Property(x => x.Overrides)
.HasJsonConversion([]);
// Configure one-to-many relationship
builder.Entity<MetadataSettings>()
.HasMany(x => x.FieldMappings)
.WithOne(x => x.MetadataSettings)
.HasForeignKey(x => x.MetadataSettingsId)
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<MetadataSettings>()
.Property(b => b.Enabled)
.HasDefaultValue(true);
builder.Entity<MetadataSettings>()
.Property(b => b.EnableCoverImage)
.HasDefaultValue(true);
#endregion
#region AppUserAuthKey
builder.Entity<AppUserAuthKey>()
.Property(a => a.Provider)
.HasDefaultValue(AuthKeyProvider.User);
#endregion
#region AppUserBookmark
builder.Entity<AppUserBookmark>(entity =>
{
entity.HasOne(b => b.Series)
.WithMany()
.HasForeignKey(b => b.SeriesId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.Volume)
.WithMany()
.HasForeignKey(b => b.VolumeId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.Chapter)
.WithMany()
.HasForeignKey(b => b.ChapterId)
.OnDelete(DeleteBehavior.Cascade);
entity.HasOne(b => b.AppUser)
.WithMany(u => u.Bookmarks)
.HasForeignKey(b => b.AppUserId)
.OnDelete(DeleteBehavior.Cascade);
});
#endregion
#region Search Indexes
// Series indexes for search
builder.Entity<Series>(entity =>
{
entity.HasIndex(s => s.NormalizedName)
.HasDatabaseName("IX_Series_NormalizedName");
entity.HasIndex(s => s.LibraryId)
.HasDatabaseName("IX_Series_LibraryId");
});
builder.Entity<SeriesMetadata>(entity =>
{
entity.HasIndex(sm => sm.AgeRating)
.HasDatabaseName("IX_SeriesMetadata_AgeRating");
// This composite helps age-restricted queries
entity.HasIndex(sm => new { sm.SeriesId, sm.AgeRating })
.HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating");
});
// Chapter indexes
builder.Entity<Chapter>(entity =>
{
entity.HasIndex(c => c.TitleName)
.HasDatabaseName("IX_Chapter_TitleName");
});
// MangaFile indexes (admin search)
builder.Entity<MangaFile>(entity =>
{
entity.HasIndex(f => f.FilePath)
.HasDatabaseName("IX_MangaFile_FilePath");
});
// AppUserBookmark composite for user lookups
builder.Entity<AppUserBookmark>(entity =>
{
entity.HasIndex(b => new { b.AppUserId, b.SeriesId })
.HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId");
});
#endregion
}
#nullable enable

View File

@ -182,21 +182,7 @@ public class ComicInfo
info.Locations = Services.Tasks.Scanner.Parser.Parser.CleanAuthor(info.Locations);
// We need to convert GTIN to ISBN
if (!string.IsNullOrEmpty(info.GTIN))
{
// This is likely a valid ISBN
if (info.GTIN[0] == '0')
{
var potentialISBN = info.GTIN.Substring(1, info.GTIN.Length - 1);
if (ArticleNumberHelper.IsValidIsbn13(potentialISBN))
{
info.Isbn = potentialISBN;
}
} else if (ArticleNumberHelper.IsValidIsbn10(info.GTIN) || ArticleNumberHelper.IsValidIsbn13(info.GTIN))
{
info.Isbn = info.GTIN;
}
}
info.Isbn = ParseGtin(info.GTIN);
if (!string.IsNullOrEmpty(info.Number))
{
@ -235,5 +221,34 @@ public class ComicInfo
return 0;
}
/// <summary>
/// For a given GTIN, attempts to parse out an ISBN and set the Isbn property.
/// </summary>
/// <param name="gtin"></param>
/// <returns></returns>
public static string ParseGtin(string? gtin)
{
if (string.IsNullOrEmpty(gtin)) return string.Empty;
// This is likely a valid ISBN
if (gtin[0] == '0')
{
var offset = gtin[1] == '-' ? 0 : 1;
var potentialIsbn = gtin[offset..];
if (ArticleNumberHelper.IsValidIsbn13(potentialIsbn))
{
return potentialIsbn;
}
}
if (ArticleNumberHelper.IsValidIsbn10(gtin) || ArticleNumberHelper.IsValidIsbn13(gtin))
{
return gtin;
}
return string.Empty;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,153 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
/// <inheritdoc />
public partial class BookmarkRelationshipAndSearchIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Clean orphaned records BEFORE adding FK constraints
migrationBuilder.Sql(@"
DELETE FROM AppUserBookmark
WHERE SeriesId NOT IN (SELECT Id FROM Series)
OR VolumeId NOT IN (SELECT Id FROM Volume)
OR ChapterId NOT IN (SELECT Id FROM Chapter);
");
migrationBuilder.DropIndex(
name: "IX_AppUserBookmark_AppUserId",
table: "AppUserBookmark");
migrationBuilder.CreateIndex(
name: "IX_SeriesMetadata_AgeRating",
table: "SeriesMetadata",
column: "AgeRating");
migrationBuilder.CreateIndex(
name: "IX_SeriesMetadata_SeriesId_AgeRating",
table: "SeriesMetadata",
columns: new[] { "SeriesId", "AgeRating" });
migrationBuilder.CreateIndex(
name: "IX_Series_NormalizedName",
table: "Series",
column: "NormalizedName");
migrationBuilder.CreateIndex(
name: "IX_MangaFile_FilePath",
table: "MangaFile",
column: "FilePath");
migrationBuilder.CreateIndex(
name: "IX_Chapter_TitleName",
table: "Chapter",
column: "TitleName");
migrationBuilder.CreateIndex(
name: "IX_AppUserBookmark_AppUserId_SeriesId",
table: "AppUserBookmark",
columns: new[] { "AppUserId", "SeriesId" });
migrationBuilder.CreateIndex(
name: "IX_AppUserBookmark_ChapterId",
table: "AppUserBookmark",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_AppUserBookmark_SeriesId",
table: "AppUserBookmark",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_AppUserBookmark_VolumeId",
table: "AppUserBookmark",
column: "VolumeId");
migrationBuilder.AddForeignKey(
name: "FK_AppUserBookmark_Chapter_ChapterId",
table: "AppUserBookmark",
column: "ChapterId",
principalTable: "Chapter",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_AppUserBookmark_Series_SeriesId",
table: "AppUserBookmark",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_AppUserBookmark_Volume_VolumeId",
table: "AppUserBookmark",
column: "VolumeId",
principalTable: "Volume",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_AppUserBookmark_Chapter_ChapterId",
table: "AppUserBookmark");
migrationBuilder.DropForeignKey(
name: "FK_AppUserBookmark_Series_SeriesId",
table: "AppUserBookmark");
migrationBuilder.DropForeignKey(
name: "FK_AppUserBookmark_Volume_VolumeId",
table: "AppUserBookmark");
migrationBuilder.DropIndex(
name: "IX_SeriesMetadata_AgeRating",
table: "SeriesMetadata");
migrationBuilder.DropIndex(
name: "IX_SeriesMetadata_SeriesId_AgeRating",
table: "SeriesMetadata");
migrationBuilder.DropIndex(
name: "IX_Series_NormalizedName",
table: "Series");
migrationBuilder.DropIndex(
name: "IX_MangaFile_FilePath",
table: "MangaFile");
migrationBuilder.DropIndex(
name: "IX_Chapter_TitleName",
table: "Chapter");
migrationBuilder.DropIndex(
name: "IX_AppUserBookmark_AppUserId_SeriesId",
table: "AppUserBookmark");
migrationBuilder.DropIndex(
name: "IX_AppUserBookmark_ChapterId",
table: "AppUserBookmark");
migrationBuilder.DropIndex(
name: "IX_AppUserBookmark_SeriesId",
table: "AppUserBookmark");
migrationBuilder.DropIndex(
name: "IX_AppUserBookmark_VolumeId",
table: "AppUserBookmark");
migrationBuilder.CreateIndex(
name: "IX_AppUserBookmark_AppUserId",
table: "AppUserBookmark",
column: "AppUserId");
}
}
}

View File

@ -306,7 +306,14 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("AppUserId");
b.HasIndex("ChapterId");
b.HasIndex("SeriesId");
b.HasIndex("VolumeId");
b.HasIndex("AppUserId", "SeriesId")
.HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId");
b.ToTable("AppUserBookmark");
});
@ -985,6 +992,9 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("TitleName")
.HasDatabaseName("IX_Chapter_TitleName");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
@ -1418,6 +1428,9 @@ namespace API.Data.Migrations
b.HasIndex("ChapterId");
b.HasIndex("FilePath")
.HasDatabaseName("IX_MangaFile_FilePath");
b.ToTable("MangaFile");
});
@ -1754,12 +1767,18 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("AgeRating")
.HasDatabaseName("IX_SeriesMetadata_AgeRating");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.HasIndex("SeriesId", "AgeRating")
.HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating");
b.ToTable("SeriesMetadata");
});
@ -2629,7 +2648,11 @@ namespace API.Data.Migrations
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("LibraryId")
.HasDatabaseName("IX_Series_LibraryId");
b.HasIndex("NormalizedName")
.HasDatabaseName("IX_Series_NormalizedName");
b.ToTable("Series");
});
@ -3383,7 +3406,31 @@ namespace API.Data.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Series", "Series")
.WithMany()
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Volume", "Volume")
.WithMany()
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
b.Navigation("Chapter");
b.Navigation("Series");
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.AppUserCollection", b =>

View File

@ -36,6 +36,8 @@ public interface IAppUserProgressRepository
Task<int> GetHighestFullyReadChapterForSeries(int seriesId, int userId);
Task<float> GetHighestFullyReadVolumeForSeries(int seriesId, int userId);
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
Task<DateTime?> GetLatestProgressForVolume(int volumeId, int userId);
Task<DateTime?> GetLatestProgressForChapter(int chapterId, int userId);
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
Task UpdateAllProgressThatAreMoreThanChapterPages();
Task<IList<FullProgressDto>> GetUserProgressForChapter(int chapterId, int userId = 0);
@ -203,6 +205,22 @@ public class AppUserProgressRepository : IAppUserProgressRepository
return list.Count == 0 ? null : list.DefaultIfEmpty().Max();
}
public async Task<DateTime?> GetLatestProgressForVolume(int volumeId, int userId)
{
var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.VolumeId == volumeId)
.Select(p => p.LastModifiedUtc)
.ToListAsync();
return list.Count == 0 ? null : list.DefaultIfEmpty().Max();
}
public async Task<DateTime?> GetLatestProgressForChapter(int chapterId, int userId)
{
return await _context.AppUserProgresses
.Where(p => p.AppUserId == userId && p.ChapterId == chapterId)
.Select(p => p.LastModifiedUtc)
.FirstOrDefaultAsync();
}
public async Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId)
{
var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.SeriesId == seriesId)

View File

@ -11,6 +11,7 @@ using API.Entities.Enums;
using API.Entities.Metadata;
using API.Extensions;
using API.Extensions.QueryExtensions;
using API.Services.Tasks.Scanner.Parser;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.EntityFrameworkCore;
@ -51,7 +52,7 @@ public interface IChapterRepository
Task<IList<string>> GetAllCoverImagesAsync();
Task<IList<Chapter>> GetAllChaptersWithCoversInDifferentEncoding(EncodeFormat format);
Task<IEnumerable<string>> GetCoverImagesForLockedChaptersAsync();
Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter);
Task AddChapterModifiers(int userId, ChapterDto chapter);
IEnumerable<Chapter> GetChaptersForSeries(int seriesId);
Task<IList<Chapter>> GetAllChaptersForSeries(int seriesId);
Task<int> GetAverageUserRating(int chapterId, int userId);
@ -59,6 +60,8 @@ public interface IChapterRepository
Task<IList<ExternalReview>> GetExternalChapterReview(int chapterId);
Task<IList<RatingDto>> GetExternalChapterRatingDtos(int chapterId);
Task<IList<ExternalRating>> GetExternalChapterRatings(int chapterId);
Task<ChapterDto?> GetCurrentlyReadingChapterAsync(int seriesId, int userId);
Task<ChapterDto?> GetFirstChapterForSeriesAsync(int seriesId, int userId);
}
public class ChapterRepository : IChapterRepository
{
@ -318,8 +321,10 @@ public class ChapterRepository : IChapterRepository
.ToListAsync();
}
public async Task<ChapterDto> AddChapterModifiers(int userId, ChapterDto chapter)
public async Task AddChapterModifiers(int userId, ChapterDto? chapter)
{
if (chapter == null) return;
var progress = await _context.AppUserProgresses.Where(x =>
x.AppUserId == userId && x.ChapterId == chapter.Id)
.AsNoTracking()
@ -337,8 +342,6 @@ public class ChapterRepository : IChapterRepository
chapter.LastReadingProgressUtc = DateTime.MinValue;
chapter.LastReadingProgress = DateTime.MinValue;
}
return chapter;
}
/// <summary>
@ -416,4 +419,61 @@ public class ChapterRepository : IChapterRepository
.SelectMany(c => c.ExternalRatings)
.ToListAsync();
}
public async Task<ChapterDto?> GetCurrentlyReadingChapterAsync(int seriesId, int userId)
{
var chapterWithProgress = await _context.AppUserProgresses
.Where(p => p.AppUserId == userId)
.Join(
_context.Chapter.Include(c => c.Volume),
p => p.ChapterId,
c => c.Id,
(p, c) => new { Chapter = c, p.PagesRead }
)
.Where(x => x.Chapter.Volume.SeriesId == seriesId)
.Where(x => x.Chapter.Volume.Number != Parser.LooseLeafVolumeNumber)
.Where(x => x.PagesRead > 0 && x.PagesRead < x.Chapter.Pages)
.OrderBy(x => x.Chapter.Volume.Number)
.ThenBy(x => x.Chapter.SortOrder)
.AsNoTracking()
.FirstOrDefaultAsync();
if (chapterWithProgress == null) return null;
// Map chapter to DTO
var dto = _mapper.Map<ChapterDto>(chapterWithProgress.Chapter);
dto.PagesRead = chapterWithProgress.PagesRead;
return dto;
}
public async Task<ChapterDto?> GetFirstChapterForSeriesAsync(int seriesId, int userId)
{
// Get the chapter entity with proper ordering
var firstChapter = await _context.Chapter
.Include(c => c.Volume)
.Where(c => c.Volume.SeriesId == seriesId)
.OrderBy(c =>
// Priority 1: Regular volumes (not loose leaf, not special)
c.Volume.Number == Parser.LooseLeafVolumeNumber ||
c.Volume.Number == Parser.SpecialVolumeNumber ? 1 : 0)
.ThenBy(c =>
// Priority 2: Loose leaf over specials
c.Volume.Number == Parser.SpecialVolumeNumber ? 1 : 0)
.ThenBy(c =>
// Priority 3: Non-special chapters
c.IsSpecial ? 1 : 0)
.ThenBy(c => c.Volume.Number)
.ThenBy(c => c.SortOrder)
.AsNoTracking()
.FirstOrDefaultAsync();
if (firstChapter == null) return null;
var dto = _mapper.Map<ChapterDto>(firstChapter);
await AddChapterModifiers(userId, dto);
return dto;
}
}

View File

@ -105,11 +105,11 @@ public class ExternalSeriesMetadataRepository : IExternalSeriesMetadataRepositor
public async Task<bool> NeedsDataRefresh(int seriesId)
{
// TODO: Add unit test
var row = await _context.ExternalSeriesMetadata
return await _context.ExternalSeriesMetadata
.Where(s => s.SeriesId == seriesId)
.FirstOrDefaultAsync();
return row == null || row.ValidUntilUtc <= DateTime.UtcNow;
.Select(s => s.ValidUntilUtc)
.Where(date => date < DateTime.UtcNow)
.AnyAsync();
}
public async Task<SeriesDetailPlusDto?> GetSeriesDetailPlusDto(int seriesId)

View File

@ -385,160 +385,165 @@ public class SeriesRepository : ISeriesRepository
public async Task<SearchResultGroupDto> SearchSeries(int userId, bool isAdmin, IList<int> libraryIds, string searchQuery, bool includeChapterAndFiles = true)
{
const int maxRecords = 15;
var result = new SearchResultGroupDto();
var searchQueryNormalized = searchQuery.ToNormalized();
var userRating = await _context.AppUser.GetUserAgeRestriction(userId);
var seriesIds = await _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating)
.Select(s => s.Id)
.ToListAsync();
var justYear = _yearRegex.Match(searchQuery).Value;
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
result.Libraries = await _context.Library
var baseSeriesQuery = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.RestrictAgainstAgeRestriction(userRating);
#region Independent Queries
var librariesTask = _context.Library
.Search(searchQuery, userId, libraryIds)
.Take(maxRecords)
.OrderBy(l => l.Name.ToLower())
.ProjectTo<LibraryDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Annotations = await _context.AppUserAnnotation
var annotationsTask = _context.AppUserAnnotation
.Where(a => a.AppUserId == userId &&
(EF.Functions.Like(a.Comment, $"%{searchQueryNormalized}%") || EF.Functions.Like(a.Context, $"%{searchQueryNormalized}%")))
(EF.Functions.Like(a.Comment, $"%{searchQueryNormalized}%") ||
EF.Functions.Like(a.Context, $"%{searchQueryNormalized}%")))
.Take(maxRecords)
.OrderBy(l => l.CreatedUtc)
.ProjectTo<AnnotationDto>(_mapper.ConfigurationProvider)
.ToListAsync();
#endregion
var justYear = _yearRegex.Match(searchQuery).Value;
var hasYearInQuery = !string.IsNullOrEmpty(justYear);
var yearComparison = hasYearInQuery ? int.Parse(justYear) : 0;
var seriesTask = baseSeriesQuery
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%"))
|| (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|| EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%")
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
.OrderBy(s => s.SortName!.Length)
.ThenBy(s => s.SortName!.ToLower())
.Take(maxRecords)
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Series = _context.Series
.Where(s => libraryIds.Contains(s.LibraryId))
.Where(s => EF.Functions.Like(s.Name, $"%{searchQuery}%")
|| (s.OriginalName != null && EF.Functions.Like(s.OriginalName, $"%{searchQuery}%"))
|| (s.LocalizedName != null && EF.Functions.Like(s.LocalizedName, $"%{searchQuery}%"))
|| (EF.Functions.Like(s.NormalizedName, $"%{searchQueryNormalized}%"))
|| (hasYearInQuery && s.Metadata.ReleaseYear == yearComparison))
.RestrictAgainstAgeRestriction(userRating)
.Include(s => s.Library)
.AsNoTracking()
.AsSplitQuery()
.OrderBy(s => s.SortName!.Length)
.ThenBy(s => s.SortName!.ToLower())
.Take(maxRecords)
.ProjectTo<SearchResultDto>(_mapper.ConfigurationProvider)
.AsEnumerable();
result.Bookmarks = (await _context.AppUserBookmark
.Join(
_context.Series,
bookmark => bookmark.SeriesId,
series => series.Id,
(bookmark, series) => new {Bookmark = bookmark, Series = series}
)
.Where(joined => joined.Bookmark.AppUserId == userId &&
(EF.Functions.Like(joined.Series.Name, $"%{searchQuery}%") ||
(joined.Series.OriginalName != null &&
EF.Functions.Like(joined.Series.OriginalName, $"%{searchQuery}%")) ||
(joined.Series.LocalizedName != null &&
EF.Functions.Like(joined.Series.LocalizedName, $"%{searchQuery}%"))))
.OrderBy(joined => joined.Series.NormalizedName.Length)
.ThenBy(joined => joined.Series.NormalizedName)
.Take(maxRecords)
.Select(joined => new BookmarkSearchResultDto()
{
SeriesName = joined.Series.Name,
LocalizedSeriesName = joined.Series.LocalizedName,
LibraryId = joined.Series.LibraryId,
SeriesId = joined.Bookmark.SeriesId,
ChapterId = joined.Bookmark.ChapterId,
VolumeId = joined.Bookmark.VolumeId
})
.ToListAsync()).DistinctBy(s => s.SeriesId);
result.ReadingLists = await _context.ReadingList
var readingListsTask = _context.ReadingList
.Search(searchQuery, userId, userRating)
.Take(maxRecords)
.ProjectTo<ReadingListDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Collections = await _context.AppUserCollection
var collectionsTask = _context.AppUserCollection
.Search(searchQuery, userId, userRating)
.Take(maxRecords)
.OrderBy(c => c.NormalizedTitle)
.ProjectTo<AppUserCollectionDto>(_mapper.ConfigurationProvider)
.ToListAsync();
// I can't work out how to map people in DB layer
var personIds = await _context.SeriesMetadata
.SearchPeople(searchQuery, seriesIds)
.Select(p => p.Id)
var bookmarksTask = _context.AppUserBookmark
.Where(b => b.AppUserId == userId)
.Where(b => libraryIds.Contains(b.Series.LibraryId))
.Where(b => EF.Functions.Like(b.Series.Name, $"%{searchQuery}%") ||
(b.Series.OriginalName != null && EF.Functions.Like(b.Series.OriginalName, $"%{searchQuery}%")) ||
(b.Series.LocalizedName != null && EF.Functions.Like(b.Series.LocalizedName, $"%{searchQuery}%")))
.OrderBy(b => b.Series.NormalizedName.Length)
.ThenBy(b => b.Series.NormalizedName)
.Select(b => new BookmarkSearchResultDto
{
SeriesName = b.Series.Name,
LocalizedSeriesName = b.Series.LocalizedName,
LibraryId = b.Series.LibraryId,
SeriesId = b.SeriesId,
ChapterId = b.ChapterId,
VolumeId = b.VolumeId
})
.Distinct()
.OrderBy(id => id)
.Take(maxRecords)
.ToListAsync();
result.Persons = await _context.Person
.Where(p => personIds.Contains(p.Id))
var seriesIdsSubquery = baseSeriesQuery.Select(s => s.Id);
var personsTask = _context.Person
.Where(p => _context.SeriesMetadataPeople
.Any(smp => smp.PersonId == p.Id &&
seriesIdsSubquery.Contains(smp.SeriesMetadata.SeriesId) &&
EF.Functions.Like(p.NormalizedName, $"%{searchQueryNormalized}%")))
.OrderBy(p => p.NormalizedName.Length)
.ThenBy(p => p.NormalizedName)
.Take(maxRecords)
.ProjectTo<PersonDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Genres = await _context.SeriesMetadata
.SearchGenres(searchQuery, seriesIds)
var genresTask = _context.Genre
.Where(g => _context.SeriesMetadata
.Any(sm => seriesIdsSubquery.Contains(sm.SeriesId) &&
sm.Genres.Any(sg => sg.Id == g.Id)) &&
EF.Functions.Like(g.NormalizedTitle, $"%{searchQueryNormalized}%"))
.Take(maxRecords)
.ProjectTo<GenreTagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Tags = await _context.SeriesMetadata
.SearchTags(searchQuery, seriesIds)
var tagsTask = _context.Tag
.Where(t => _context.SeriesMetadata
.Any(sm => seriesIdsSubquery.Contains(sm.SeriesId) &&
sm.Tags.Any(st => st.Id == t.Id)) &&
EF.Functions.Like(t.NormalizedTitle, $"%{searchQueryNormalized}%"))
.Take(maxRecords)
.ProjectTo<TagDto>(_mapper.ConfigurationProvider)
.ToListAsync();
result.Files = [];
result.Chapters = (List<ChapterDto>) [];
// Run separate DB queries in parallel
await Task.WhenAll(
librariesTask, annotationsTask, seriesTask, readingListsTask,
collectionsTask, bookmarksTask, personsTask, genresTask, tagsTask);
var result = new SearchResultGroupDto
{
Libraries = await librariesTask,
Annotations = await annotationsTask,
Series = await seriesTask,
ReadingLists = await readingListsTask,
Collections = await collectionsTask,
Bookmarks = await bookmarksTask,
Persons = await personsTask,
Genres = await genresTask,
Tags = await tagsTask,
Files = [],
Chapters = []
};
if (includeChapterAndFiles)
{
var fileIds = _context.Series
.Where(s => seriesIds.Contains(s.Id))
.AsSplitQuery()
.SelectMany(s => s.Volumes)
.SelectMany(v => v.Chapters)
.SelectMany(c => c.Files.Select(f => f.Id));
// Need to check if an admin
var user = await _context.AppUser.FirstAsync(u => u.Id == userId);
if (await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
{
result.Files = await _context.MangaFile
.Where(m => EF.Functions.Like(m.FilePath, $"%{searchQuery}%") && fileIds.Contains(m.Id))
.AsSplitQuery()
.OrderBy(f => f.FilePath)
.Take(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
result.Chapters = await _context.Chapter
.Include(c => c.Files)
// Use EXISTS subquery pattern instead of loading IDs
var chaptersQuery = _context.Chapter
.Where(c => c.Volume.Series.LibraryId > 0 && // Ensure navigation works
libraryIds.Contains(c.Volume.Series.LibraryId))
.Where(c => EF.Functions.Like(c.TitleName, $"%{searchQuery}%")
|| EF.Functions.Like(c.ISBN, $"%{searchQuery}%")
|| EF.Functions.Like(c.Range, $"%{searchQuery}%")
)
.Where(c => c.Files.All(f => fileIds.Contains(f.Id)))
.AsSplitQuery()
|| EF.Functions.Like(c.Range, $"%{searchQuery}%"));
// Apply age restriction via series
chaptersQuery = chaptersQuery
.Where(c => baseSeriesQuery.Any(s => s.Id == c.Volume.SeriesId));
result.Chapters = await chaptersQuery
.OrderBy(c => c.TitleName.Length)
.ThenBy(c => c.TitleName)
.Take(maxRecords)
.ProjectTo<ChapterDto>(_mapper.ConfigurationProvider)
.ToListAsync();
if (isAdmin)
{
result.Files = await _context.MangaFile
.Where(f => EF.Functions.Like(f.FilePath, $"%{searchQuery}%"))
.Where(f => libraryIds.Contains(f.Chapter.Volume.Series.LibraryId))
.Where(f => baseSeriesQuery.Any(s => s.Id == f.Chapter.Volume.SeriesId))
.OrderBy(f => f.FilePath)
.Take(maxRecords)
.ProjectTo<MangaFileDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
}
return result;

View File

@ -133,10 +133,11 @@ public interface IUserRepository
Task<string?> GetCoverImageAsync(int userId, int requestingUserId);
Task<string?> GetPersonCoverImageAsync(int personId);
Task<IList<AuthKeyDto>> GetAuthKeysForUserId(int userId);
Task<IList<AuthKeyDto>> GetAllAuthKeysDtosWithExpiration();
Task<AppUserAuthKey?> GetAuthKeyById(int authKeyId);
Task<DateTime?> GetAuthKeyExpiration(string authKey, int userId);
Task<AppUserSocialPreferences> GetSocialPreferencesForUser(int userId);
Task<AppUserPreferences> GetPreferencesForUser(int userId);
}
public class UserRepository : IUserRepository
@ -970,6 +971,9 @@ public class UserRepository : IUserRepository
return await _context.AppUserAuthKey
.Where(k => k.Key == authKey)
.HasNotExpired()
.Include(k => k.AppUser)
.ThenInclude(u => u.UserRoles)
.ThenInclude(ur => ur.Role)
.Select(k => k.AppUser)
.ProjectTo<UserDto>(_mapper.ConfigurationProvider)
.FirstOrDefaultAsync();
@ -1073,6 +1077,14 @@ public class UserRepository : IUserRepository
.ToListAsync();
}
public async Task<IList<AuthKeyDto>> GetAllAuthKeysDtosWithExpiration()
{
return await _context.AppUserAuthKey
.Where(k => k.ExpiresAtUtc != null)
.ProjectTo<AuthKeyDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<AppUserAuthKey?> GetAuthKeyById(int authKeyId)
{
return await _context.AppUserAuthKey
@ -1080,6 +1092,14 @@ public class UserRepository : IUserRepository
.FirstOrDefaultAsync();
}
public async Task<DateTime?> GetAuthKeyExpiration(string authKey, int userId)
{
return await _context.AppUserAuthKey
.Where(k => k.Key == authKey && k.AppUserId == userId)
.Select(k => k.ExpiresAtUtc)
.FirstOrDefaultAsync();
}
public async Task<AppUserSocialPreferences> GetSocialPreferencesForUser(int userId)
{
return await _context.AppUserPreferences

View File

@ -37,12 +37,11 @@ public interface IVolumeRepository
Task<string?> GetVolumeCoverImageAsync(int volumeId);
Task<IList<int>> GetChapterIdsByVolumeIds(IReadOnlyList<int> volumeIds);
Task<IList<VolumeDto>> GetVolumesDtoAsync(int seriesId, int userId, VolumeIncludes includes = VolumeIncludes.Chapters);
Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files);
Task<Volume?> GetVolumeByIdAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files);
Task<VolumeDto?> GetVolumeDtoAsync(int volumeId, int userId);
Task<IEnumerable<Volume>> GetVolumesForSeriesAsync(IList<int> seriesIds, bool includeChapters = false);
Task<IEnumerable<Volume>> GetVolumes(int seriesId);
Task<IList<Volume>> GetVolumesById(IList<int> volumeIds, VolumeIncludes includes = VolumeIncludes.None);
Task<Volume?> GetVolumeByIdAsync(int volumeId);
Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat);
Task<IEnumerable<string>> GetCoverImagesForLockedVolumesAsync();
}
@ -198,7 +197,7 @@ public class VolumeRepository : IVolumeRepository
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
public async Task<Volume?> GetVolumeAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files)
public async Task<Volume?> GetVolumeByIdAsync(int volumeId, VolumeIncludes includes = VolumeIncludes.Files)
{
return await _context.Volume
.Includes(includes)
@ -228,11 +227,6 @@ public class VolumeRepository : IVolumeRepository
return volumes;
}
public async Task<Volume?> GetVolumeByIdAsync(int volumeId)
{
return await _context.Volume.FirstOrDefaultAsync(x => x.Id == volumeId);
}
public async Task<IList<Volume>> GetAllWithCoversInDifferentEncoding(EncodeFormat encodeFormat)
{
var extension = encodeFormat.GetExtension();

View File

@ -37,6 +37,12 @@ public class AppUserBookmark : IEntityDate
// Relationships
[JsonIgnore]
public AppUser AppUser { get; set; } = null!;
[JsonIgnore]
public Series Series { get; set; } = null!;
[JsonIgnore]
public Volume Volume { get; set; } = null!;
[JsonIgnore]
public Chapter Chapter { get; set; } = null!;
public int AppUserId { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }

View File

@ -4,6 +4,7 @@ using API.Data;
using API.Helpers;
using API.Middleware;
using API.Services;
using API.Services.Caching;
using API.Services.Plus;
using API.Services.Reading;
using API.Services.Store;
@ -13,12 +14,14 @@ using API.Services.Tasks.Scanner;
using API.SignalR;
using API.SignalR.Presence;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using NeoSmart.Caching.Sqlite;
namespace API.Extensions;
@ -84,6 +87,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<ILocalizationService, LocalizationService>();
services.AddScoped<ISettingsService, SettingsService>();
services.AddScoped<IAuthKeyCacheInvalidator, AuthKeyCacheInvalidator>();
services.AddScoped<IKavitaPlusApiService, KavitaPlusApiService>();
@ -94,6 +98,7 @@ public static class ApplicationServiceExtensions
services.AddScoped<IWantToReadSyncService, WantToReadSyncService>();
services.AddScoped<IOidcService, OidcService>();
services.AddScoped<IEntityDisplayService, EntityDisplayService>();
services.AddScoped<IReadingHistoryService, ReadingHistoryService>();
services.AddScoped<IClientDeviceService, ClientDeviceService>();
@ -126,7 +131,8 @@ public static class ApplicationServiceExtensions
options.SizeLimit = Configuration.CacheSize * 1024 * 1024; // 75 MB
options.CompactionPercentage = 0.1; // LRU compaction, Evict 10% when limit reached
});
// Needs to be registered after the memory cache, as it depends on it
services.AddSingleton<TicketSerializer>();
services.AddSingleton<ITicketStore, CustomTicketStore>();
services.AddSwaggerGen(g =>
@ -137,6 +143,8 @@ public static class ApplicationServiceExtensions
private static void AddSqLite(this IServiceCollection services)
{
services.AddSqliteCache("config/cache.db");
services.AddDbContextPool<DataContext>(options =>
{
options.UseSqlite("Data source=config/kavita.db", builder =>

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text;
@ -7,7 +8,9 @@ using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
using API.Entities.Progress;
using API.Helpers;
using API.Middleware;
using API.Services;
using Kavita.Common;
using Microsoft.AspNetCore.Authentication;
@ -74,28 +77,39 @@ public static class IdentityServiceExtensions
var auth = services.AddAuthentication(DynamicHybrid);
var enableOidc = oidcSettings.Enabled && services.SetupOpenIdConnectAuthentication(auth, oidcSettings, environment);
auth.AddPolicyScheme(DynamicHybrid, JwtBearerDefaults.AuthenticationScheme, options =>
auth.AddPolicyScheme(DynamicHybrid, LocalIdentity, options =>
{
options.ForwardDefaultSelector = ctx =>
{
if (!enableOidc) return LocalIdentity;
if (ctx.Request.Path.StartsWithSegments(OidcCallback) ||
ctx.Request.Path.StartsWithSegments(OidcLogoutCallback))
// Priority 1: Check for API/Auth Key
var apiKey = AuthKeyAuthenticationHandler.ExtractAuthKey(ctx.Request);
if (!string.IsNullOrEmpty(apiKey))
{
return OpenIdConnect;
return AuthKeyAuthenticationOptions.SchemeName;
}
// Priority 2: OIDC paths and cookies
if (enableOidc)
{
if (ctx.Request.Path.StartsWithSegments(OidcCallback) ||
ctx.Request.Path.StartsWithSegments(OidcLogoutCallback))
{
return OpenIdConnect;
}
if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName))
{
return OpenIdConnect;
}
}
// Priority 3: JWT Bearer token
if (ctx.Request.Headers.Authorization.Count != 0)
{
return LocalIdentity;
}
if (ctx.Request.Cookies.ContainsKey(OidcService.CookieName))
{
return OpenIdConnect;
}
// Default to JWT
return LocalIdentity;
};
});
@ -109,14 +123,31 @@ public static class IdentityServiceExtensions
ValidateIssuer = false,
ValidateAudience = false,
ValidIssuer = "Kavita",
NameClaimType = JwtRegisteredClaimNames.Name,
RoleClaimType = ClaimTypes.Role,
};
options.Events = new JwtBearerEvents
{
OnMessageReceived = SetTokenFromQuery,
OnTokenValidated = ctx =>
{
(ctx.Principal?.Identity as ClaimsIdentity)?.AddClaim(new Claim("AuthType", nameof(AuthenticationType.JWT)));
return Task.CompletedTask;
}
};
});
// Add Bearer as an alias to LocalIdentity
auth.AddPolicyScheme(JwtBearerDefaults.AuthenticationScheme, JwtBearerDefaults.AuthenticationScheme, options =>
{
options.ForwardDefaultSelector = _ => LocalIdentity;
});
auth.AddScheme<AuthKeyAuthenticationOptions, AuthKeyAuthenticationHandler>(
AuthKeyAuthenticationOptions.SchemeName,
options => { });
services.AddAuthorizationBuilder()
.AddPolicy(PolicyGroups.AdminPolicy, policy => policy.RequireRole(PolicyConstants.AdminRole))

View File

@ -44,8 +44,8 @@ public static class ActivityFilter
queryable = queryable
.Where(d => filter.Libraries.Contains(d.LibraryId) && d.ReadingSession.AppUserId == userId)
.WhereIf(onlyCompleted, d => d.EndPage >= d.Chapter.Pages)
.WhereIf(startTime != null, d => d.StartTime >= startTime)
.WhereIf(endTime != null, d => d.EndTime <= endTime);
.WhereIf(startTime != null, d => d.StartTimeUtc >= startTime)
.WhereIf(endTime != null, d => d.EndTimeUtc <= endTime);
if (isAggregate)
{

View File

@ -263,6 +263,9 @@ public class AutoMapperProfiles : Profile
CreateMap<AppUser, UserDto>()
.ForMember(dest => dest.Roles,
opt =>
opt.MapFrom(src => src.UserRoles.Select(r => r.Role.Name)))
.ForMember(dest => dest.AgeRestriction,
opt =>
opt.MapFrom(src => new AgeRestrictionDto()
@ -471,5 +474,12 @@ public class AutoMapperProfiles : Profile
CreateMap<AppUserAuthKey, AuthKeyDto>();
#region Deprecated Code
CreateMap<Chapter, ChapterMetadataDto>();
#endregion
}
}

View File

@ -48,6 +48,8 @@ public static class LogLevelOptions
// Suppress noisy loggers that add no value
.MinimumLevel.Override("Microsoft.AspNetCore.ResponseCaching.ResponseCachingMiddleware", LogEventLevel.Error)
.MinimumLevel.Override("Microsoft.AspNetCore", LogEventLevel.Error)
.MinimumLevel.Override("Microsoft.AspNetCore.Authentication", LogEventLevel.Error)
.MinimumLevel.Override("API.Middleware.AuthKeyAuthenticationHandler", LogEventLevel.Error)
.Enrich.FromLogContext()
.Enrich.WithThreadId()
.Enrich.With(new ApiKeyEnricher())

View File

@ -0,0 +1,125 @@
using System;
using System.Collections.Generic;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities.Progress;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace API.Middleware;
#nullable enable
public class AuthKeyAuthenticationOptions : AuthenticationSchemeOptions
{
public const string SchemeName = "AuthKey";
}
public class AuthKeyAuthenticationHandler : AuthenticationHandler<AuthKeyAuthenticationOptions>
{
private readonly IUnitOfWork _unitOfWork;
private readonly HybridCache _cache;
private static readonly HybridCacheEntryOptions CacheOptions = new()
{
Expiration = TimeSpan.FromMinutes(15),
LocalCacheExpiration = TimeSpan.FromMinutes(15)
};
public AuthKeyAuthenticationHandler(
IOptionsMonitor<AuthKeyAuthenticationOptions> options,
ILoggerFactory logger,
UrlEncoder encoder,
IUnitOfWork unitOfWork,
HybridCache cache)
: base(options, logger, encoder)
{
_unitOfWork = unitOfWork;
_cache = cache;
}
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
var apiKey = ExtractAuthKey(Request);
if (string.IsNullOrEmpty(apiKey))
{
return AuthenticateResult.NoResult();
}
try
{
var cacheKey = CreateCacheKey(apiKey);
var user = await _cache.GetOrCreateAsync(
cacheKey,
(apiKey, _unitOfWork),
static async (state, cancel) =>
await state._unitOfWork.UserRepository.GetUserDtoByAuthKeyAsync(state.apiKey),
CacheOptions,
cancellationToken: Context.RequestAborted).ConfigureAwait(false);
if (user?.Id == null || string.IsNullOrEmpty(user.Username))
{
return AuthenticateResult.Fail("Invalid API Key");
}
var claims = new List<Claim>()
{
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Name, user.Username),
new("AuthType", nameof(AuthenticationType.AuthKey))
};
if (user.Roles != null && user.Roles.Any())
{
claims.AddRange(user.Roles.Select(role => new Claim(ClaimTypes.Role, role)));
}
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
catch (Exception ex)
{
Logger.LogError(ex, "Auth Key authentication failed");
return AuthenticateResult.Fail("Auth Key authentication failed");
}
}
public static string? ExtractAuthKey(HttpRequest request)
{
// Check query string
if (request.Query.TryGetValue("apiKey", out var apiKeyQuery))
{
return apiKeyQuery.ToString();
}
// Check header
if (request.Headers.TryGetValue(Headers.ApiKey, out var authHeader))
{
return authHeader.ToString();
}
// Check if embedded in route parameters (e.g., /api/somepath/{apiKey}/other)
if (request.RouteValues.TryGetValue("apiKey", out var routeKey))
{
return routeKey?.ToString();
}
return null;
}
public static string CreateCacheKey(string keyValue)
{
return $"authKey_{keyValue}";
}
}

View File

@ -1,15 +1,12 @@
using System;
using System.IdentityModel.Tokens.Jwt;
using System.Linq;
using System.Security.Claims;
using System.Threading.Tasks;
using API.Data;
using API.Entities.Progress;
using API.Extensions;
using API.Services;
using API.Services.Store;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Caching.Hybrid;
using Microsoft.Extensions.Logging;
namespace API.Middleware;
@ -20,50 +17,33 @@ namespace API.Middleware;
/// (JWT, Auth Key, OIDC) and provides a unified IUserContext for downstream components.
/// Must run after UseAuthentication() and UseAuthorization().
/// </summary>
public class UserContextMiddleware(RequestDelegate next, ILogger<UserContextMiddleware> logger, HybridCache cache)
public class UserContextMiddleware(RequestDelegate next, ILogger<UserContextMiddleware> logger)
{
private static readonly HybridCacheEntryOptions ApiKeyCacheOptions = new()
{
Expiration = TimeSpan.FromMinutes(15),
LocalCacheExpiration = TimeSpan.FromMinutes(15)
};
public async Task InvokeAsync(
HttpContext context,
UserContext userContext, // Scoped service
IUnitOfWork unitOfWork)
UserContext userContext)
{
try
{
// Clear any previous context (shouldn't be necessary, but defensive)
userContext.Clear();
// Check if endpoint allows anonymous access
var endpoint = context.GetEndpoint();
var allowAnonymous = endpoint?.Metadata.GetMetadata<IAllowAnonymous>() != null;
// ALWAYS attempt to resolve user identity, regardless of [AllowAnonymous]
var (userId, username, authType) = await ResolveUserIdentityAsync(context, unitOfWork);
if (userId.HasValue)
if (context.User.Identity?.IsAuthenticated == true)
{
userContext.SetUserContext(userId.Value, username!, authType);
var userId = TryGetUserIdFromClaim(context.User, ClaimTypes.NameIdentifier);
logger.LogTrace(
"Resolved user context: UserId={UserId}, AuthType={AuthType}",
userId, authType);
}
else if (!allowAnonymous)
{
// No user resolved on a protected endpoint - this is a problem
// Authorization middleware will handle returning 401/403
logger.LogWarning("Could not resolve user identity for protected endpoint: {Path}", context.Request.Path.ToString().Sanitize());
}
else
{
// No user resolved but endpoint allows anonymous - this is fine
logger.LogTrace("No user identity resolved for anonymous endpoint: {Path}", context.Request.Path.ToString().Sanitize());
var username = context.User.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
var roles = context.User.FindAll(ClaimTypes.Role)
.Select(c => c.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (userId.HasValue && username != null)
{
var authType = TryParseAuthTypeClaim(context.User) ?? AuthenticationType.Unknown;
userContext.SetUserContext(userId.Value, username, authType, roles);
}
}
}
catch (Exception ex)
@ -75,122 +55,12 @@ public class UserContextMiddleware(RequestDelegate next, ILogger<UserContextMidd
await next(context);
}
private async Task<(int? userId, string? username, AuthenticationType authType)> ResolveUserIdentityAsync(
HttpContext context,
IUnitOfWork unitOfWork)
private static AuthenticationType? TryParseAuthTypeClaim(ClaimsPrincipal user)
{
// Priority 1: ALWAYS check for Auth Key first (query string or path parameter)
// Auth Keys work even on [AllowAnonymous] endpoints (like OPDS)
var apiKeyResult = await TryResolveFromAuthKeyAsync(context, unitOfWork);
if (apiKeyResult.userId.HasValue)
{
return apiKeyResult;
}
// Priority 3: Check for JWT or OIDC claims
if (context.User.Identity?.IsAuthenticated == true)
{
return ResolveFromClaims(context);
}
return (null, null, AuthenticationType.Unknown);
}
/// <summary>
/// Tries to resolve auth key and support apiKey from pre-v0.8.6 (switching from apikey -> auth keys)
/// </summary>
private async Task<(int? userId, string? username, AuthenticationType authType)> TryResolveFromAuthKeyAsync(
HttpContext context,
IUnitOfWork unitOfWork)
{
string? apiKey = null;
// Check query string: ?apiKey=xxx
if (context.Request.Query.TryGetValue("apiKey", out var apiKeyQuery))
{
apiKey = apiKeyQuery.ToString();
}
// Check path for OPDS endpoints: /api/opds/{apiKey}/...
if (string.IsNullOrEmpty(apiKey))
{
var path = context.Request.Path.Value ?? string.Empty;
if (path.Contains("/api/opds/", StringComparison.OrdinalIgnoreCase))
{
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
var opdsIndex = Array.FindIndex(segments, s =>
s.Equals("opds", StringComparison.OrdinalIgnoreCase));
if (opdsIndex >= 0 && opdsIndex + 1 < segments.Length)
{
apiKey = segments[opdsIndex + 1];
}
}
}
// Check if embedded in route parameters (e.g., /api/somepath/{apiKey}/other)
if (string.IsNullOrEmpty(apiKey) && context.Request.RouteValues.TryGetValue("apiKey", out var _))
{
apiKey = apiKeyQuery.ToString();
}
if (string.IsNullOrEmpty(apiKey))
{
return (null, null, AuthenticationType.Unknown);
}
try
{
var cacheKey = $"authKey_{apiKey}";
var result = await cache.GetOrCreateAsync(
cacheKey,
(apiKey, unitOfWork),
async (state, cancel) =>
{
// Auth key will work with legacy apiKey and new auth key
var user = await state.unitOfWork.UserRepository.GetUserDtoByAuthKeyAsync(state.apiKey);
return (user?.Id, user?.Username);
},
ApiKeyCacheOptions,
cancellationToken: context.RequestAborted);
if (result is {Id: not null, Username: not null})
{
logger.LogTrace("Resolved user {UserId} from Auth Key for path {Path}", result.Id, context.Request.Path.ToString().Sanitize());
return (result.Id, result.Username, AuthenticationType.AuthKey);
}
logger.LogWarning("Invalid Auth Key provided for path {Path}", context.Request.Path);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to resolve user from Auth Key");
}
return (null, null, AuthenticationType.Unknown);
}
private static (int? userId, string? username, AuthenticationType authType) ResolveFromClaims(HttpContext context)
{
var claims = context.User;
// Check if OIDC authentication
if (context.Request.Cookies.ContainsKey(OidcService.CookieName))
{
var userId = TryGetUserIdFromClaim(claims, ClaimTypes.NameIdentifier);
var username = claims.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
return (userId, username, AuthenticationType.OIDC);
}
// JWT authentication
var jwtUserId = TryGetUserIdFromClaim(claims, ClaimTypes.NameIdentifier);
var jwtUsername = claims.FindFirst(JwtRegisteredClaimNames.Name)?.Value;
return (jwtUserId, jwtUsername, AuthenticationType.JWT);
var authTypeClaim = user.FindFirst("AuthType")?.Value;
return authTypeClaim != null && Enum.TryParse<AuthenticationType>(authTypeClaim, out var authType)
? authType
: null;
}
private static int? TryGetUserIdFromClaim(ClaimsPrincipal claims, string claimType)

View File

@ -0,0 +1,26 @@
using System.Threading;
using System.Threading.Tasks;
using API.Middleware;
using Microsoft.Extensions.Caching.Hybrid;
namespace API.Services.Caching;
public interface IAuthKeyCacheInvalidator
{
/// <summary>
/// Invalidates the cached authentication data for a specific auth key.
/// Call this when a key is rotated or deleted.
/// </summary>
/// <param name="keyValue">The actual key value (not the ID)</param>
/// <param name="cancellationToken">Cancellation token</param>
Task InvalidateAsync(string keyValue, CancellationToken cancellationToken = default);
}
public class AuthKeyCacheInvalidator(HybridCache cache) : IAuthKeyCacheInvalidator
{
public async Task InvalidateAsync(string keyValue, CancellationToken cancellationToken = default)
{
var cacheKey = AuthKeyAuthenticationHandler.CreateCacheKey(keyValue);
await cache.RemoveAsync(cacheKey, cancellationToken);
}
}

View File

@ -451,6 +451,7 @@ public class ClientDeviceService(DataContext context, IMapper mapper, ILogger<Cl
public static string GetCacheKey(int userId, string? uiFingerprint, ClientInfoData clientInfo)
{
var deviceIdPart = string.IsNullOrEmpty(uiFingerprint) ? clientInfo.Browser : uiFingerprint;
deviceIdPart = string.IsNullOrEmpty(deviceIdPart) ? clientInfo.UserAgent : deviceIdPart;
return $"device_tracking_{userId}_{deviceIdPart}";
}

View File

@ -0,0 +1,272 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.Entities;
using API.Entities.Enums;
using API.Services.Tasks.Scanner.Parser;
namespace API.Services;
#nullable enable
public interface IEntityDisplayService
{
Task<(string displayName, bool neededRename)> GetVolumeDisplayName( VolumeDto volume, int userId, EntityDisplayOptions options);
Task<string> GetChapterDisplayName(ChapterDto chapter, int userId, EntityDisplayOptions options);
Task<string> GetChapterDisplayName(Chapter chapter, int userId, EntityDisplayOptions options);
Task<string> GetEntityDisplayName(ChapterDto chapter, int userId, EntityDisplayOptions options);
}
/// <summary>
/// Service responsible for generating user-friendly display names for Volumes and Chapters.
/// Centralizes naming logic to avoid exposing internal encodings (-100000).
/// </summary>
public class EntityDisplayService(ILocalizationService localizationService, IUnitOfWork unitOfWork) : IEntityDisplayService
{
/// <summary>
/// Generates a user-friendly display name for a Volume.
/// </summary>
/// <param name="volume">The volume to generate a name for</param>
/// <param name="userId">User ID for localization</param>
/// <param name="options">Display options</param>
/// <returns>Tuple of (displayName, neededRename) where neededRename indicates if the volume was modified</returns>
public async Task<(string displayName, bool neededRename)> GetVolumeDisplayName( VolumeDto volume, int userId, EntityDisplayOptions options)
{
// Handle special volumes - these shouldn't be displayed as regular volumes
if (volume.IsSpecial() || volume.IsLooseLeaf())
{
return (string.Empty, false);
}
var libraryType = options.LibraryType;
var neededRename = false;
// Book/LightNovel treatment - use chapter title as volume name
if (libraryType is LibraryType.Book or LibraryType.LightNovel)
{
var firstChapter = volume.Chapters.FirstOrDefault();
if (firstChapter == null)
{
return (string.Empty, false);
}
// Skip special chapters
if (firstChapter.IsSpecial)
{
return (string.Empty, false);
}
// Use chapter's title name if available
if (!string.IsNullOrEmpty(firstChapter.TitleName))
{
neededRename = true;
return (firstChapter.TitleName, neededRename);
}
// Fallback: extract from Range if it's not a loose leaf marker
if (!firstChapter.Range.Equals(Parser.LooseLeafVolume))
{
var title = Path.GetFileNameWithoutExtension(firstChapter.Range);
if (!string.IsNullOrEmpty(title))
{
neededRename = true;
var displayName = string.IsNullOrEmpty(volume.Name)
? title
: $"{volume.Name} - {title}";
return (displayName, neededRename);
}
}
return (string.Empty, false);
}
// Standard volume naming for Comics/Manga
if (options.IncludePrefix)
{
var volumeLabel = options.VolumePrefix
?? await localizationService.Translate(userId, "volume-num", string.Empty);
neededRename = true;
return ($"{volumeLabel.Trim()} {volume.Name}".Trim(), neededRename);
}
return (volume.Name, neededRename);
}
/// <summary>
/// Generates a user-friendly display name for a Chapter (DTO).
/// </summary>
public async Task<string> GetChapterDisplayName( ChapterDto chapter, int userId, EntityDisplayOptions options)
{
return await GetChapterDisplayNameCore(
chapter.IsSpecial,
chapter.Range,
chapter.Title,
userId,
options);
}
/// <summary>
/// Generates a user-friendly display name for a Chapter (Entity).
/// </summary>
public async Task<string> GetChapterDisplayName( Chapter chapter, int userId, EntityDisplayOptions options)
{
return await GetChapterDisplayNameCore(
chapter.IsSpecial,
chapter.Range,
chapter.Title,
userId,
options);
}
/// <summary>
/// Smart method that generates display name for a chapter, automatically detecting if it needs
/// to fetch the volume name instead (for loose leaf volumes in book libraries).
/// This is the recommended method for most scenarios as it handles internal encodings.
/// </summary>
/// <param name="chapter">The chapter to generate a name for</param>
/// <param name="userId">User ID for localization</param>
/// <param name="options">Display options</param>
/// <returns>User-friendly display name</returns>
public async Task<string> GetEntityDisplayName( ChapterDto chapter, int userId, EntityDisplayOptions options)
{
// Detect if this is a loose leaf volume that should be displayed as a volume name
if (chapter.Title == Parser.LooseLeafVolume)
{
var volume = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(chapter.VolumeId, userId);
if (volume != null)
{
var (label, _) = await GetVolumeDisplayName(volume, userId, options);
if (!string.IsNullOrEmpty(label))
{
return label;
}
}
}
// Standard chapter display
return await GetChapterDisplayName(chapter, userId, options);
}
/// <summary>
/// Core implementation for chapter display name generation.
/// </summary>
private async Task<string> GetChapterDisplayNameCore( bool isSpecial, string range, string? title, int userId, EntityDisplayOptions options)
{
// Handle special chapters - use cleaned title or fallback to range
if (isSpecial)
{
if (!string.IsNullOrEmpty(title))
{
return Parser.CleanSpecialTitle(title);
}
// Fallback to cleaned range (filename)
return Parser.CleanSpecialTitle(range);
}
var libraryType = options.LibraryType;
var useHash = ShouldUseHashSymbol(libraryType, options.ForceHashSymbol);
var hashSpot = useHash ? "#" : string.Empty;
// Generate base chapter name based on library type
var baseChapter = libraryType switch
{
LibraryType.Book => await localizationService.Translate(userId, "book-num", title ?? range),
LibraryType.LightNovel => await localizationService.Translate(userId, "book-num", range),
LibraryType.Comic => await localizationService.Translate(userId, "issue-num", hashSpot, range),
LibraryType.ComicVine => await localizationService.Translate(userId, "issue-num", hashSpot, range),
LibraryType.Manga => await localizationService.Translate(userId, "chapter-num", range),
LibraryType.Image => await localizationService.Translate(userId, "chapter-num", range),
_ => await localizationService.Translate(userId, "chapter-num", range)
};
// Append title suffix if requested and title differs from range
if (options.IncludeTitleSuffix &&
!string.IsNullOrEmpty(title) &&
libraryType != LibraryType.Book &&
title != range)
{
baseChapter += $" - {title}";
}
return baseChapter;
}
/// <summary>
/// Determines if hash symbol should be used based on library type and override.
/// </summary>
private static bool ShouldUseHashSymbol(LibraryType libraryType, bool? forceHashSymbol)
{
if (forceHashSymbol.HasValue)
{
return forceHashSymbol.Value;
}
// Smart default: Comics use hash
return libraryType is LibraryType.Comic or LibraryType.ComicVine;
}
}
/// <summary>
/// Options for controlling entity display name generation.
/// </summary>
public class EntityDisplayOptions
{
/// <summary>
/// The library type context for the entity.
/// </summary>
public LibraryType LibraryType { get; set; }
/// <summary>
/// Whether to append the chapter title as a suffix (e.g., "Chapter 5 - The Beginning").
/// Default: true
/// </summary>
public bool IncludeTitleSuffix { get; set; } = true;
/// <summary>
/// Force inclusion or exclusion of hash symbol (#) for issues.
/// If null, smart default based on library type is used.
/// </summary>
public bool? ForceHashSymbol { get; set; } = null;
/// <summary>
/// Whether to include the volume prefix (e.g., "Volume 1" vs "1").
/// Default: true
/// </summary>
public bool IncludePrefix { get; set; } = true;
/// <summary>
/// Pre-translated volume prefix to avoid redundant localization calls.
/// If null, will be fetched via localization service.
/// </summary>
public string? VolumePrefix { get; set; } = null;
/// <summary>
/// Creates default options for a given library type.
/// </summary>
public static EntityDisplayOptions Default(LibraryType libraryType) => new()
{
LibraryType = libraryType
};
/// <summary>
/// Creates options with title suffix disabled (useful for compact displays).
/// </summary>
public static EntityDisplayOptions WithoutTitleSuffix(LibraryType libraryType) => new()
{
LibraryType = libraryType,
IncludeTitleSuffix = false
};
/// <summary>
/// Creates options without prefix (e.g., returns "5" instead of "Volume 5").
/// </summary>
public static EntityDisplayOptions WithoutPrefix(LibraryType libraryType) => new()
{
LibraryType = libraryType,
IncludePrefix = false
};
}

View File

@ -14,8 +14,10 @@ using API.DTOs.Email;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Progress;
using API.Extensions;
using API.Helpers.Builders;
using API.Services.Tasks.Metadata;
using Hangfire;
using Flurl.Http;
using Kavita.Common;
@ -65,6 +67,7 @@ public interface IOidcService
/// <remarks>It is registered as a singleton only if oidc is enabled. So must be nullable and optional</remarks>
public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userManager,
IUnitOfWork unitOfWork, IAccountService accountService, IEmailService emailService,
ICoverDbService coverDbService,
[FromServices] ConfigurationManager<OpenIdConnectConfiguration>? configurationManager = null): IOidcService
{
public const string LibraryAccessPrefix = "library-";
@ -73,6 +76,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
public const string RefreshToken = "refresh_token";
public const string IdToken = "id_token";
public const string ExpiresAt = "expires_at";
/// The name of the Auth Cookie set by .NET
public const string CookieName = ".AspNetCore.Cookies";
public static readonly List<string> DefaultScopes = ["openid", "profile", "offline_access", "roles", "email"];
@ -227,7 +231,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
{
return await NewUserFromOpenIdConnect(request, settings, principal, oidcId);
}
catch (KavitaException e)
catch (KavitaException)
{
throw;
}
@ -380,19 +384,22 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
{
if (!settings.SyncUserSettings || user.IdentityProvider != IdentityProvider.OpenIdConnect) return;
// Never sync the default user
var defaultAdminUser = await unitOfWork.UserRepository.GetDefaultAdminUser();
if (defaultAdminUser.Id == user.Id) return;
logger.LogDebug("Syncing user {UserId} from OIDC", user.Id);
try
{
await SyncEmail(request, settings, claimsPrincipal, user);
await SyncUsername(claimsPrincipal, user);
await SyncRoles(settings, claimsPrincipal, user);
await SyncLibraries(settings, claimsPrincipal, user);
await SyncAgeRestriction(settings, claimsPrincipal, user);
if (user.Id != defaultAdminUser.Id)
{
await SyncEmail(request, settings, claimsPrincipal, user);
await SyncUsername(claimsPrincipal, user);
await SyncRoles(settings, claimsPrincipal, user);
await SyncLibraries(settings, claimsPrincipal, user);
await SyncAgeRestriction(settings, claimsPrincipal, user);
}
SyncExtras(claimsPrincipal, user);
if (unitOfWork.HasChanges())
{
@ -407,6 +414,20 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
}
}
private void SyncExtras(ClaimsPrincipal claimsPrincipal, AppUser user)
{
var picture = claimsPrincipal.FindFirst(JwtRegisteredClaimNames.Picture)?.Value;
// Only sync if the user has no image, I know this is no true sync as changing it in your idp
// will require action on Kavita. But it's less effort than saving if the user has set it themselves
// Will just need to be documented on the wiki.
if (!string.IsNullOrEmpty(picture) && string.IsNullOrEmpty(user.CoverImage))
{
// Run in background to not block http thread, pass id to Hangfire doesn't kill itself
BackgroundJob.Enqueue(() => coverDbService.SetUserCoverByUrl(user.Id, picture, false));
}
}
private async Task SyncEmail(HttpRequest request, OidcConfigDto settings, ClaimsPrincipal claimsPrincipal, AppUser user)
{
var email = claimsPrincipal.FindFirstValue(ClaimTypes.Email);
@ -503,10 +524,16 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
.Where(s => rolesFromToken.Contains(s, StringComparer.OrdinalIgnoreCase))
.ToList();
// Ensure that Admin Role and ReadOnly aren't both selected
if (roles.Contains(PolicyConstants.AdminRole))
{
roles = roles.Where(r => r != PolicyConstants.ReadOnlyRole).ToList();
}
logger.LogDebug("Syncing access roles for user {UserId}, found roles {Roles}", user.Id, roles);
var errors = (await accountService.UpdateRolesForUser(user, roles)).ToList();
if (errors.Any())
if (errors.Count != 0)
{
logger.LogError("Failed to sync roles {Errors}", errors.Select(x => x.Description).ToList());
throw new KavitaException("errors.oidc.syncing-user");
@ -658,6 +685,7 @@ public class OidcService(ILogger<OidcService> logger, UserManager<AppUser> userM
new(ClaimTypes.NameIdentifier, user.Id.ToString()),
new(JwtRegisteredClaimNames.Name, user.UserName ?? string.Empty),
new(ClaimTypes.Name, user.UserName ?? string.Empty),
new("AuthType", nameof(AuthenticationType.OIDC))
};
var userManager = services.GetRequiredService<UserManager<AppUser>>();

View File

@ -728,7 +728,7 @@ public class OpdsService : IOpdsService
throw new OpdsException(await _localizationService.Translate(userId, "series-doesnt-exist"));
}
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId, VolumeIncludes.Chapters);
var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId, VolumeIncludes.Chapters);
if (volume == null)
{
throw new OpdsException(await _localizationService.Translate(userId, "volume-doesnt-exist"));
@ -782,7 +782,7 @@ public class OpdsService : IOpdsService
throw new OpdsException(await _localizationService.Translate(userId, "chapter-doesnt-exist"));
}
var volume = await _unitOfWork.VolumeRepository.GetVolumeAsync(volumeId);
var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(volumeId);
var chapterName = await _seriesService.FormatChapterName(userId, libraryType);
var feed = CreateFeed( $"{series.Name} - Volume {volume!.Name} - {chapterName} {chapterId}",

View File

@ -520,9 +520,10 @@ public class ExternalMetadataService : IExternalMetadataService
externalSeriesMetadata.AverageExternalRating = extRatings.Count != 0 ? (int) extRatings
.Average(r => r.AverageScore) : 0;
if (result.MalId.HasValue) externalSeriesMetadata.MalId = result.MalId.Value;
if (result.AniListId.HasValue) externalSeriesMetadata.AniListId = result.AniListId.Value;
if (result.CbrId.HasValue) externalSeriesMetadata.CbrId = result.CbrId.Value;
// prefer what was passed in (manual match), fall back to what K+ returned
externalSeriesMetadata.MalId = data.MalId ?? result.MalId ?? 0;
externalSeriesMetadata.AniListId = data.AniListId ?? result.AniListId ?? 0;
externalSeriesMetadata.CbrId = data.CbrId ?? result.CbrId ?? 0;
// If there is metadata and the user has metadata download turned on
var madeMetadataModification = false;

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Data.Migrations;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Progress;
@ -12,6 +13,7 @@ using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Progress;
using API.Entities.User;
using API.Extensions;
using API.Services.Plus;
using API.Services.Tasks.Scanner.Parser;
@ -38,14 +40,16 @@ public interface IReaderService
Task MarkVolumesUntilAsRead(AppUser user, int seriesId, int volumeNumber);
IDictionary<int, int> GetPairs(IEnumerable<FileDimensionDto> dimensions);
Task<string> GetThumbnail(Chapter chapter, int pageNum, IEnumerable<string> cachedImages);
Task<RereadDto> CheckSeriesForReRead(int userId, int seriesId);
Task<RereadDto> CheckVolumeForReRead(int userId, int volumeId, int seriesId, int libraryId);
Task<RereadDto> CheckChapterForReRead(int userId, int chapterId, int seriesId, int libraryId);
}
public class ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger, IEventHub eventHub, IImageService imageService,
IDirectoryService directoryService, IScrobblingService scrobblingService, IReadingSessionService readingSessionService,
IClientInfoAccessor clientInfoAccessor)
IClientInfoAccessor clientInfoAccessor, ISeriesService seriesService, IEntityDisplayService entityDisplayService)
: IReaderService
{
private readonly IClientInfoAccessor _clientInfoAccessor = clientInfoAccessor;
private readonly ChapterSortComparerDefaultLast _chapterSortComparerDefaultLast = ChapterSortComparerDefaultLast.Default;
private readonly ChapterSortComparerDefaultFirst _chapterSortComparerForInChapterSorting = ChapterSortComparerDefaultFirst.Default;
private readonly ChapterSortComparerSpecialsLast _chapterSortComparerSpecialsLast = ChapterSortComparerSpecialsLast.Default;
@ -532,62 +536,28 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger
/// <returns></returns>
public async Task<ChapterDto> GetContinuePoint(int seriesId, int userId)
{
var volumes = (await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
var anyUserProgress =
await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId);
if (!anyUserProgress)
var hasProgress = await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId);
if (!hasProgress)
{
// I think i need a way to sort volumes last
volumes = volumes.OrderBy(v => v.MinNumber, _chapterSortComparerSpecialsLast).ToList();
// 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];
// Get first chapter only
return await unitOfWork.ChapterRepository.GetFirstChapterForSeriesAsync(seriesId, userId);
}
// Loop through all chapters that are not in volume 0
var volumeChapters = volumes
.WhereNotLooseLeaf()
.SelectMany(v => v.Chapters)
var currentlyReading = await unitOfWork.ChapterRepository.GetCurrentlyReadingChapterAsync(seriesId, userId);
if (currentlyReading != null)
{
return currentlyReading;
}
var volumes = (await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, userId)).ToList();
var allChapters = volumes
.OrderBy(v => v.MinNumber, _chapterSortComparerDefaultLast)
.SelectMany(v => v.Chapters.OrderBy(c => c.SortOrder))
.ToList();
// 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.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, _chapterSortComparerDefaultLast)
.SelectMany(v => v.Chapters.OrderBy(c => c.SortOrder))
.ToList());
return FindNextReadingChapter(allChapters);
}
private static ChapterDto FindNextReadingChapter(IList<ChapterDto> volumeChapters)
@ -779,6 +749,190 @@ public class ReaderService(IUnitOfWork unitOfWork, ILogger<ReaderService> logger
}
}
public async Task<RereadDto> CheckSeriesForReRead(int userId, int seriesId)
{
var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.Library);
if (series == null) return RereadDto.Dont();
var libraryType = series.Library.Type;
var options = EntityDisplayOptions.Default(libraryType);
var continuePoint = await GetContinuePoint(seriesId, userId);
var lastProgress = await unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(seriesId, userId);
var continuePointLabel = await entityDisplayService.GetEntityDisplayName(continuePoint, userId, options);
if (lastProgress == null || !await unitOfWork.AppUserProgressRepository.AnyUserProgressForSeriesAsync(seriesId, userId))
{
return new RereadDto
{
ShouldPrompt = false,
ChapterOnContinue = new RereadChapterDto(series.LibraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format)
};
}
var userPreferences = await unitOfWork.UserRepository.GetPreferencesForUser(userId);
return await BuildRereadDto(
userId,
userPreferences,
series.LibraryId,
seriesId,
libraryType,
continuePoint,
continuePointLabel,
lastProgress.Value,
getPrevChapter: async () =>
{
var chapterId = await GetPrevChapterIdAsync(seriesId, continuePoint.VolumeId, continuePoint.Id, userId);
if (chapterId == -1) return null;
return await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
},
isValidPrevChapter: prevChapter => prevChapter != null
);
}
public async Task<RereadDto> CheckVolumeForReRead(int userId, int volumeId, int seriesId, int libraryId)
{
var userPreferences = await unitOfWork.UserRepository.GetPreferencesForUser(userId);
var volume = await unitOfWork.VolumeRepository.GetVolumeDtoAsync(volumeId, userId);
if (volume == null) return RereadDto.Dont();
var libraryType = await unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId);
var continuePoint = FindNextReadingChapter([.. volume.Chapters]);
var options = EntityDisplayOptions.Default(libraryType);
var continuePointLabel = await entityDisplayService.GetEntityDisplayName(continuePoint, userId, options);
var lastProgress = await unitOfWork.AppUserProgressRepository.GetLatestProgressForVolume(volumeId, userId);
// Check if there's no progress on the volume
if (lastProgress == null || volume.PagesRead == 0)
{
return new RereadDto
{
ShouldPrompt = false,
ChapterOnContinue = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format)
};
}
return await BuildRereadDto(
userId,
userPreferences,
libraryId,
seriesId,
libraryType,
continuePoint,
continuePointLabel,
lastProgress.Value,
getPrevChapter: async () =>
{
var chapterId = await GetPrevChapterIdAsync(seriesId, continuePoint.VolumeId, continuePoint.Id, userId);
if (chapterId == -1) return null;
return await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
},
isValidPrevChapter: prevChapter => prevChapter != null && prevChapter.VolumeId == volume.Id
);
}
private async Task<RereadDto> BuildRereadDto(
int userId,
AppUserPreferences userPreferences,
int libraryId,
int seriesId,
LibraryType libraryType,
ChapterDto continuePoint,
string continuePointLabel,
DateTime lastProgress,
Func<Task<ChapterDto?>> getPrevChapter,
Func<ChapterDto?, bool> isValidPrevChapter)
{
var daysSinceLastProgress = (DateTime.UtcNow - lastProgress).Days;
var reReadForTime = daysSinceLastProgress != 0 && daysSinceLastProgress > userPreferences.PromptForRereadsAfter;
// Next up chapter has progress, re-read if it's fully read or long ago
if (continuePoint.PagesRead > 0)
{
var reReadChapterDto = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format);
return new RereadDto
{
ShouldPrompt = continuePoint.PagesRead >= continuePoint.Pages || reReadForTime,
TimePrompt = continuePoint.PagesRead < continuePoint.Pages,
DaysSinceLastRead = daysSinceLastProgress,
ChapterOnContinue = reReadChapterDto,
ChapterOnReread = reReadChapterDto
};
}
var prevChapter = await getPrevChapter();
// There is no valid previous chapter, use continue point for re-read
if (!isValidPrevChapter(prevChapter))
{
var reReadChapterDto = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format);
return new RereadDto
{
ShouldPrompt = continuePoint.PagesRead >= continuePoint.Pages || reReadForTime,
TimePrompt = continuePoint.PagesRead < continuePoint.Pages,
DaysSinceLastRead = daysSinceLastProgress,
ChapterOnContinue = reReadChapterDto,
ChapterOnReread = reReadChapterDto
};
}
// Prompt if it's been a while and might need a refresher (start with the prev chapter)
var prevChapterLabel = await seriesService.FormatChapterTitle(userId, prevChapter!, libraryType);
return new RereadDto
{
ShouldPrompt = reReadForTime,
TimePrompt = true,
DaysSinceLastRead = daysSinceLastProgress,
ChapterOnContinue = new RereadChapterDto(libraryId, seriesId, continuePoint.Id, continuePointLabel, continuePoint.Format),
ChapterOnReread = new RereadChapterDto(libraryId, seriesId, prevChapter!.Id, prevChapterLabel, prevChapter.Format)
};
}
public async Task<RereadDto> CheckChapterForReRead(int userId, int chapterId, int seriesId, int libraryId)
{
var userPreferences = await unitOfWork.UserRepository.GetPreferencesForUser(userId);
var chapter = await unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId, userId);
if (chapter == null) return RereadDto.Dont();
var lastProgress = await unitOfWork.AppUserProgressRepository.GetLatestProgressForChapter(chapterId, userId);
var libraryType = await unitOfWork.LibraryRepository.GetLibraryTypeAsync(libraryId);
var options = EntityDisplayOptions.Default(libraryType);
var chapterLabel = await entityDisplayService.GetEntityDisplayName(chapter, userId, options);
var reReadChapter = new RereadChapterDto(libraryId, seriesId, chapterId, chapterLabel, chapter.Format);
// No progress, read it
if (lastProgress == null || chapter.PagesRead == 0)
{
return new RereadDto
{
ShouldPrompt = false,
ChapterOnContinue = reReadChapter,
};
}
var daysSinceLastProgress = (DateTime.UtcNow - lastProgress.Value).Days;
var reReadForTime = daysSinceLastProgress != 0 && daysSinceLastProgress > userPreferences.PromptForRereadsAfter;
// Prompt if fully read or long ago
return new RereadDto
{
ShouldPrompt = chapter.PagesRead >= chapter.Pages || reReadForTime,
TimePrompt = chapter.PagesRead < chapter.Pages,
DaysSinceLastRead = daysSinceLastProgress,
ChapterOnContinue = reReadChapter,
ChapterOnReread = reReadChapter
};
}
/// <summary>
/// Formats a Chapter name based on the library it's in
/// </summary>

View File

@ -91,6 +91,7 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
.Select(p => (int?) p.PagesRead)
.SumAsync() ?? 0;
// TODO: this needs to use AppUserReadingSessions
var timeSpentReading = await TimeSpentReadingForUsersAsync(new List<int>() {userId}, libraryIds);
var totalWordsRead = (long) Math.Round(await context.AppUserProgresses
@ -106,9 +107,9 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
.Where(p => p.PagesRead >= context.Chapter.Single(c => c.Id == p.ChapterId).Pages)
.CountAsync();
var lastActive = await context.AppUserProgresses
var lastActive = await context.AppUserReadingSession
.Where(p => p.AppUserId == userId)
.Select(p => p.LastModified)
.Select(u => u.EndTimeUtc)
.DefaultIfEmpty()
.MaxAsync();
@ -133,6 +134,7 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
.ToListAsync();
// TODO: Move this to ReadingSession
// New solution. Calculate total hours then divide by number of weeks from time account was created (or min reading event) till now
var averageReadingTimePerWeek = await context.AppUserProgresses
.Where(p => p.AppUserId == userId)
@ -167,15 +169,13 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
}
return new UserReadStatistics()
{
TotalPagesRead = totalPagesRead,
TotalWordsRead = totalWordsRead,
TimeSpentReading = timeSpentReading,
ChaptersRead = chaptersRead,
LastActive = lastActive,
LastActiveUtc = lastActive,
PercentReadPerLibrary = totalProgressByLibrary,
AvgHoursPerWeekSpentReading = averageReadingTimePerWeek
};
@ -673,7 +673,6 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
if (sessionActivityData.Count == 0) return result;
// Group and aggregate in memory (minimal data already fetched)
var dailyStats = sessionActivityData
.GroupBy(x => x.SessionDate)
.Select(dayGroup => new
@ -949,7 +948,7 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
var readsPerTag = await context.AppUserReadingSessionActivityData
var readsPerTagTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.GroupBy(d => d.SeriesId)
.Select(d => new
@ -989,32 +988,34 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
.Take(10)
.ToListAsync();
var totalMissingData = await context.AppUserReadingSessionActivityData
var totalMissingDataTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Select(p => p.SeriesId)
.Distinct()
.Join(context.SeriesMetadata, p => p, sm => sm.SeriesId, (g, m) => m.Tags)
.CountAsync(g => !g.Any());
var totalReads = await context.AppUserReadingSessionActivityData
var totalReadsTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Select(p => p.SeriesId)
.Distinct()
.CountAsync();
var totalReadTags = await context.AppUserReadingSessionActivityData
var totalReadTagsTask = context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser)
.Join(context.Chapter, p => p.ChapterId, c => c.Id, (p, c) => c.Tags)
.SelectMany(g => g.Select(gg => gg.NormalizedTitle))
.Distinct()
.CountAsync();
await Task.WhenAll(readsPerTagTask, totalMissingDataTask, totalReadsTask, totalReadTagsTask);
return new BreakDownDto<string>()
{
Data = readsPerTag,
Missing = totalMissingData,
Total = totalReads,
TotalOptions = totalReadTags,
Data = await readsPerTagTask,
Missing = await totalMissingDataTask,
Total = await totalReadsTask,
TotalOptions = await totalReadTagsTask,
};
}
@ -1146,7 +1147,7 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
var hourStats = sessions
.SelectMany(session =>
{
var hours = new List<(int hour, TimeSpan timeSpent)>();
var hours = new List<(DateOnly day, int hour, TimeSpan timeSpent)>();
var current = session.StartTime;
while (current < session.EndTime)
@ -1156,24 +1157,31 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
var endOfPeriod = new[] { hourEnd, sessionEnd }.Min();
var timeSpent = endOfPeriod - current;
hours.Add((current.Hour, timeSpent));
hours.Add((DateOnly.FromDateTime(current), current.Hour, timeSpent));
current = endOfPeriod;
}
return hours;
})
.GroupBy(x => new { x.day, x.hour })
.Select(g => new
{
g.Key.day,
g.Key.hour,
totalTimeSpent = g.Sum(x => x.timeSpent.TotalMinutes)
})
.GroupBy(x => x.hour)
.ToDictionary(
g => g.Key,
g => (long)g.Average(x => x.timeSpent.TotalMinutes)
g => g.Average(x => x.totalTimeSpent)
);
var data = Enumerable.Range(0, 24)
.Select(hour => new StatCount<int>
{
Value = hour,
Count = hourStats.GetValueOrDefault(hour, 0),
Count = (long) Math.Ceiling(hourStats.TryGetValue(hour, out var value) ? value : 0),
})
.ToList();
@ -1349,9 +1357,13 @@ public class StatisticService(ILogger<StatisticService> logger, DataContext cont
var socialPreferences = await unitOfWork.UserRepository.GetSocialPreferencesForUser(userId);
var requestingUser = await unitOfWork.UserRepository.GetUserByIdAsync(requestingUserId);
// It makes no sense to filter this in time. Remove them
filter.StartDate = null;
filter.EndDate = null;
// It makes no sense to filter this in by month etc. Trim to year
filter.StartDate = filter.StartDate.HasValue
? new DateTime(filter.StartDate.Value.Year, 1, 1, 0, 0, 0, DateTimeKind.Utc)
: null;
filter.EndDate = filter.EndDate.HasValue
? new DateTime(filter.EndDate.Value.Year, 12, 31, 23, 59, 59, 0, 0, DateTimeKind.Utc)
: null;
return await context.AppUserReadingSessionActivityData
.ApplyStatsFilter(filter, userId, socialPreferences, requestingUser, isAggregate: true)

View File

@ -1,9 +1,10 @@
using System;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Caching.Distributed;
namespace API.Services.Store;
@ -13,8 +14,7 @@ namespace API.Services.Store;
/// due the large header size. Instead, the key is used.
/// </summary>
/// <param name="cache"></param>
/// <remarks>Note that this store is in memory, so OIDC authenticated users are logged out after restart</remarks>
public class CustomTicketStore(IMemoryCache cache): ITicketStore
public class CustomTicketStore(IDistributedCache cache, TicketSerializer ticketSerializer): ITicketStore
{
public async Task<string> StoreAsync(AuthenticationTicket ticket)
@ -31,11 +31,7 @@ public class CustomTicketStore(IMemoryCache cache): ITicketStore
public Task RenewAsync(string key, AuthenticationTicket ticket)
{
var options = new MemoryCacheEntryOptions
{
Priority = CacheItemPriority.NeverRemove,
Size = 1,
};
var options = new DistributedCacheEntryOptions();
var expiresUtc = ticket.Properties.ExpiresUtc;
if (expiresUtc.HasValue)
@ -47,20 +43,31 @@ public class CustomTicketStore(IMemoryCache cache): ITicketStore
options.SlidingExpiration = TimeSpan.FromDays(7);
}
cache.Set(key, ticket, options);
return Task.CompletedTask;
return cache.SetAsync(key, ticketSerializer.Serialize(ticket), options);
}
public Task<AuthenticationTicket> RetrieveAsync(string key)
public async Task<AuthenticationTicket> RetrieveAsync(string key)
{
return Task.FromResult(cache.Get<AuthenticationTicket>(key));
var bytes = await cache.GetAsync(key);
if (bytes == null) return CreateFailureTicket();
return ticketSerializer.Deserialize(bytes);
}
public Task RemoveAsync(string key)
{
cache.Remove(key);
return cache.RemoveAsync(key);
}
return Task.CompletedTask;
private static AuthenticationTicket CreateFailureTicket()
{
var identity = new ClaimsIdentity();
var principal = new ClaimsPrincipal(identity);
var properties = new AuthenticationProperties
{
ExpiresUtc = DateTimeOffset.UtcNow.AddMinutes(-1), // Already expired
};
return new AuthenticationTicket(principal, properties, "Cookies");
}
}

View File

@ -1,4 +1,7 @@
using API.Entities.Progress;
using System;
using System.Collections.Generic;
using System.Linq;
using API.Entities.Progress;
using Kavita.Common;
namespace API.Services.Store;
@ -24,16 +27,23 @@ public interface IUserContext
/// </summary>
/// <remarks>Warning! Username's can contain .. and /, do not use folders or filenames explicitly with the Username</remarks>
string? GetUsername();
/// <summary>
/// The Roles associated with the Authenticated user
/// </summary>
IReadOnlyList<string> Roles { get; }
/// <summary>
/// Returns true if the current user is authenticated.
/// </summary>
bool IsAuthenticated { get; }
/// <summary>
/// Gets the authentication method used (JWT, Auth Key, OIDC).
/// </summary>
AuthenticationType GetAuthenticationType();
/// <summary>
/// Returns true if the current user is authenticated.
/// </summary>
bool IsAuthenticated { get; }
bool HasRole(string role);
bool HasAnyRole(params string[] roles);
bool HasAllRoles(params string[] roles);
}
public class UserContext : IUserContext
@ -41,6 +51,7 @@ public class UserContext : IUserContext
private int? _userId;
private string? _username;
private AuthenticationType _authType;
private List<string> _roles = new();
public int? GetUserId() => _userId;
@ -54,14 +65,16 @@ public class UserContext : IUserContext
public AuthenticationType GetAuthenticationType() => _authType;
public bool IsAuthenticated { get; private set; }
public IReadOnlyList<string> Roles => _roles.AsReadOnly();
// Internal method used by middleware to set context
internal void SetUserContext(int userId, string username, AuthenticationType authType)
internal void SetUserContext(int userId, string username, AuthenticationType authType, IEnumerable<string> roles)
{
_userId = userId;
_username = username;
_authType = authType;
IsAuthenticated = true;
_roles = roles?.ToList() ?? [];
}
internal void Clear()
@ -70,5 +83,21 @@ public class UserContext : IUserContext
_username = null;
_authType = AuthenticationType.Unknown;
IsAuthenticated = false;
_roles.Clear();
}
public bool HasRole(string role)
{
return _roles.Any(r => r.Equals(role, StringComparison.OrdinalIgnoreCase));
}
public bool HasAnyRole(params string[] roles)
{
return roles.Any(HasRole);
}
public bool HasAllRoles(params string[] roles)
{
return roles.All(HasRole);
}
}

View File

@ -9,6 +9,7 @@ using API.Entities.Enums;
using API.Entities.Enums.User;
using API.Helpers;
using API.Helpers.Converters;
using API.Services.Caching;
using API.Services.Plus;
using API.Services.Reading;
using API.Services.Tasks;
@ -68,6 +69,7 @@ public class TaskScheduler : ITaskScheduler
private readonly IWantToReadSyncService _wantToReadSyncService;
private readonly IEventHub _eventHub;
private readonly IEmailService _emailService;
private readonly IAuthKeyCacheInvalidator _authKeyCacheInvalidator;
public static BackgroundJobServer Client => new ();
public const string ScanQueue = "scan";
@ -115,7 +117,8 @@ public class TaskScheduler : ITaskScheduler
IThemeService themeService, IWordCountAnalyzerService wordCountAnalyzerService, IStatisticService statisticService,
IMediaConversionService mediaConversionService, IScrobblingService scrobblingService, ILicenseService licenseService,
IExternalMetadataService externalMetadataService, ISmartCollectionSyncService smartCollectionSyncService,
IWantToReadSyncService wantToReadSyncService, IEventHub eventHub, IEmailService emailService)
IWantToReadSyncService wantToReadSyncService, IEventHub eventHub, IEmailService emailService,
IAuthKeyCacheInvalidator authKeyCacheInvalidator)
{
_cacheService = cacheService;
_logger = logger;
@ -137,6 +140,7 @@ public class TaskScheduler : ITaskScheduler
_wantToReadSyncService = wantToReadSyncService;
_eventHub = eventHub;
_emailService = emailService;
_authKeyCacheInvalidator = authKeyCacheInvalidator;
_defaultRetryPolicy = Policy
.Handle<Exception>()
@ -255,19 +259,22 @@ public class TaskScheduler : ITaskScheduler
LicenseService.Cron, RecurringJobOptions);
// KavitaPlus Scrobbling (every hour) - randomise minutes to spread requests out for K+
var randomMinute = Rnd.Next(0, 60);
RecurringJob.AddOrUpdate(ProcessScrobblingEventsId, () => _scrobblingService.ProcessUpdatesSinceLastSync(),
Cron.Hourly(Rnd.Next(0, 60)), RecurringJobOptions);
Cron.Hourly(randomMinute), RecurringJobOptions);
RecurringJob.AddOrUpdate(ProcessProcessedScrobblingEventsId, () => _scrobblingService.ClearProcessedEvents(),
Cron.Daily, RecurringJobOptions);
// Backfilling/Freshening Reviews/Rating/Recommendations
var randomKPlusBackfill = Rnd.Next(1, 5);
RecurringJob.AddOrUpdate(KavitaPlusDataRefreshId,
() => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(Rnd.Next(1, 5)),
() => _externalMetadataService.FetchExternalDataTask(), Cron.Daily(randomKPlusBackfill),
RecurringJobOptions);
// This shouldn't be so close to fetching data due to Rate limit concerns
var randomKPlusStackSync = Rnd.Next(6, 10);
RecurringJob.AddOrUpdate(KavitaPlusStackSyncId,
() => _smartCollectionSyncService.Sync(), Cron.Daily(Rnd.Next(6, 10)),
() => _smartCollectionSyncService.Sync(), Cron.Daily(randomKPlusStackSync),
RecurringJobOptions);
RecurringJob.AddOrUpdate(KavitaPlusWantToReadSyncId,
@ -275,6 +282,7 @@ public class TaskScheduler : ITaskScheduler
RecurringJobOptions);
}
/// <summary>
/// Removes any Kavita+ Recurring Jobs
/// </summary>
@ -298,11 +306,14 @@ public class TaskScheduler : ITaskScheduler
if (!allowStatCollection)
{
_logger.LogDebug("User has opted out of stat collection, not registering tasks");
RecurringJob.RemoveIfExists(ReportStatsTaskId);
return;
}
_logger.LogDebug("Scheduling stat collection daily");
RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(Rnd.Next(0, 22)), RecurringJobOptions);
var hour = Rnd.Next(0, 22);
_logger.LogDebug("Scheduling stat collection daily at {Hour}:00", hour);
RecurringJob.AddOrUpdate(ReportStatsTaskId, () => _statsService.Send(), Cron.Daily(hour), RecurringJobOptions);
}
@ -559,13 +570,13 @@ public class TaskScheduler : ITaskScheduler
// Check if key is expired
var expiredKeys = user.AuthKeys
.Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc >= DateTime.UtcNow)
.Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc <= DateTime.UtcNow)
.ToList();
var expiringSoonKeys = user.AuthKeys
.Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc >= DateTime.UtcNow.Subtract(TimeSpan.FromDays(7)))
.Where(k => k is {Provider: AuthKeyProvider.User, ExpiresAtUtc: not null} && k.ExpiresAtUtc <= DateTime.UtcNow.Subtract(TimeSpan.FromDays(7)))
.ToList();
if (expiringSoonKeys.Any())
if (expiringSoonKeys.Count != 0)
{
var expiringSoonLatestDate = expiringSoonKeys.Max(k => k.ExpiresAtUtc);
if (await ShouldSendAuthKeyExpirationReminder(user.Id, expiringSoonLatestDate!.Value, EmailService.AuthKeyExpiringSoonTemplate))
@ -575,14 +586,19 @@ public class TaskScheduler : ITaskScheduler
}
if (expiredKeys.Any())
if (expiredKeys.Count != 0)
{
var expiringSoonLatestDate = expiringSoonKeys.Max(k => k.ExpiresAtUtc);
if (await ShouldSendAuthKeyExpirationReminder(user.Id, expiringSoonLatestDate!.Value,
var expiredLatestDate = expiredKeys.Max(k => k.ExpiresAtUtc);
if (await ShouldSendAuthKeyExpirationReminder(user.Id, expiredLatestDate!.Value,
EmailService.AuthKeyExpiredTemplate))
{
await _emailService.SendAuthKeyExpiredEmail(user.Id, expiredKeys);
}
foreach (var expiredKey in expiredKeys)
{
await _authKeyCacheInvalidator.InvalidateAsync(expiredKey.Key);
}
}
}
}
@ -660,16 +676,13 @@ public class TaskScheduler : ITaskScheduler
if (ret) return true;
if (checkRunningJobs)
{
var runningJobs = JobStorage.Current.GetMonitoringApi().ProcessingJobs(0, int.MaxValue);
return runningJobs.Exists(j =>
j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) &&
j.Value.Job.Method.Name.Equals(methodName) &&
j.Value.Job.Method.DeclaringType.Name.Equals(className));
}
if (!checkRunningJobs) return false;
return false;
var runningJobs = JobStorage.Current.GetMonitoringApi().ProcessingJobs(0, int.MaxValue);
return runningJobs.Exists(j =>
j.Value.Job.Method.DeclaringType != null && j.Value.Job.Args.SequenceEqual(args) &&
j.Value.Job.Method.Name.Equals(methodName) &&
j.Value.Job.Method.DeclaringType.Name.Equals(className));
}

View File

@ -33,6 +33,7 @@ public interface ICoverDbService
Task SetPersonCoverByUrl(Person person, string url, bool fromBase64 = true, bool checkNoImagePlaceholder = false, bool chooseBetterImage = true);
Task SetSeriesCoverByUrl(Series series, string url, bool fromBase64 = true, bool chooseBetterImage = false);
Task SetChapterCoverByUrl(Chapter chapter, string url, bool fromBase64 = true, bool chooseBetterImage = false);
Task SetUserCoverByUrl(int userId, string url, bool fromBase64 = true, bool chooseBetterImage = false);
Task SetUserCoverByUrl(AppUser user, string url, bool fromBase64 = true, bool chooseBetterImage = false);
}
@ -719,6 +720,14 @@ public class CoverDbService : ICoverDbService
}
}
public async Task SetUserCoverByUrl(int userId, string url, bool fromBase64 = true, bool chooseBetterImage = false)
{
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.UserPreferences);
if (user == null) return;
await SetUserCoverByUrl(user, url, fromBase64, chooseBetterImage);
}
public async Task SetUserCoverByUrl(AppUser user, string url, bool fromBase64 = true, bool chooseBetterImage = false)
{
if (!string.IsNullOrEmpty(url))

View File

@ -85,6 +85,12 @@ public static partial class Parser
public static readonly Regex CssImageUrlRegex = new(@"(url\((?!data:).(?!data:))" + "(?<Filename>(?!data:)[^\"']*)" + @"(.\))",
MatchOptions, RegexTimeout);
/// <summary>
/// An Appropriate guess at an ASIN being valid
/// </summary>
public static readonly Regex AsinRegex = new(@"^(B0|BT)[0-9A-Z]{8}$",
MatchOptions, RegexTimeout);
private static readonly Regex ImageRegex = new(ImageFileExtensions,
MatchOptions, RegexTimeout);
@ -1300,6 +1306,17 @@ public static partial class Parser
return filename;
}
/// <summary>
/// Checks if code is an Amazon ASIN
/// </summary>
/// <param name="asin"></param>
/// <returns></returns>
public static bool IsLikelyValidAsin(string? asin)
{
if (string.IsNullOrEmpty(asin)) return false;
return AsinRegex.Match(asin).Success;
}
[GeneratedRegex(SupportedExtensions)]
private static partial Regex SupportedExtensionsRegex();

View File

@ -48,7 +48,7 @@ public class StatsService : IStatsService
private readonly UserManager<AppUser> _userManager;
private readonly IEmailService _emailService;
private readonly ICacheService _cacheService;
private readonly string _apiUrl = "";
private readonly string _apiUrl;
private const string ApiKey = "MsnvA2DfQqxSK5jh"; // It's not important this is public, just a way to keep bots from hitting the API willy-nilly
public StatsService(ILogger<StatsService> logger, IUnitOfWork unitOfWork, DataContext context,

View File

@ -1,4 +1,5 @@
using System;
using API.DTOs.Account;
using API.DTOs.Reader;
using API.DTOs.Update;
using API.Entities.Person;
@ -166,6 +167,14 @@ public static class MessageFactory
/// A Session is closing
/// </summary>
public const string SessionClose = "SessionClose";
/// <summary>
/// Auth key has been rotated, created
/// </summary>
public const string AuthKeyUpdate = nameof(AuthKeyUpdate);
/// <summary>
/// An Auth key has been deleted
/// </summary>
public const string AuthKeyDeleted = nameof(AuthKeyDeleted);
@ -738,4 +747,28 @@ public static class MessageFactory
}
};
}
public static SignalRMessage AuthKeyUpdatedEvent(AuthKeyDto authKey)
{
return new SignalRMessage
{
Name = AuthKeyUpdate,
Body = new
{
AuthKey = authKey
}
};
}
public static SignalRMessage AuthKeyDeletedEvent(int id)
{
return new SignalRMessage
{
Name = AuthKeyDeleted,
Body = new
{
Id = id
}
};
}
}

View File

@ -153,19 +153,24 @@ public class Startup
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var filePath = Path.Combine(AppContext.BaseDirectory, xmlFile);
c.IncludeXmlComments(filePath, true);
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme {
c.AddSecurityDefinition("AuthKey", new OpenApiSecurityScheme
{
Description = "Auth Key authentication. Enter your Auth key from your user settings",
Name = Headers.ApiKey,
In = ParameterLocation.Header,
Description = "Please insert JWT with Bearer into field",
Name = "Authorization",
Type = SecuritySchemeType.ApiKey
Type = SecuritySchemeType.ApiKey,
Scheme = "ApiKeyScheme"
});
c.AddSecurityRequirement((document) => new OpenApiSecurityRequirement()
{
[new OpenApiSecuritySchemeReference("bearer", document)] = []
[new OpenApiSecuritySchemeReference("apiKey", document)] = []
});
c.AddServer(new OpenApiServer
{
Url = "{protocol}://{hostpath}",

View File

@ -19,9 +19,23 @@ public static class Configuration
public const string DefaultOidcClientId = "kavita";
private static readonly string AppSettingsFilename = Path.Join("config", GetAppSettingFilename());
public static readonly string KavitaPlusApiUrl = "https://plus.kavitareader.com";
public static readonly string KavitaPlusApiUrl = GetKavitaPlusApiUrl();
public const string StatsApiUrl = "https://stats.kavitareader.com";
private static string GetKavitaPlusApiUrl()
{
var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
var isDevelopment = environment == Environments.Development;
if (isDevelopment && Environment.UserName.Equals("Joe", StringComparison.OrdinalIgnoreCase))
{
return "http://localhost:5020";
}
return "https://plus.kavitareader.com";
}
public static int Port
{
get => GetPort(GetAppSettingFilename());

View File

@ -0,0 +1,18 @@
import {MangaFormat} from "../manga-format";
export type RereadPrompt = {
shouldPrompt: boolean;
timePrompt: boolean;
daysSinceLastRead: number;
chapterOnContinue: RereadChapter;
chapterOnReread: RereadChapter;
}
export type RereadChapter = {
libraryId: number;
seriesId: number;
chapterId: number;
label: string;
format: MangaFormat,
}

View File

@ -87,6 +87,33 @@ export class AccountService {
switchMap(() => this.refreshAccount()))
.subscribe(() => {});
this.messageHub.messages$.pipe(
filter(evt => evt.event === EVENTS.AuthKeyUpdate),
map(evt => evt.payload as {authKey: AuthKey}),
tap(({authKey}) => {
const authKeys = this.currentUser!.authKeys.map(k => k.id === authKey.id ? authKey : k);
this.setCurrentUser({
...this.currentUser!,
authKeys: authKeys,
}, false);
}),
).subscribe();
this.messageHub.messages$.pipe(
filter(evt => evt.event === EVENTS.AuthKeyDeleted),
map(evt => evt.payload as {id: number}),
tap(({id}) => {
const authKeys = this.currentUser!.authKeys.filter(k => k.id !== id);
this.setCurrentUser({
...this.currentUser!,
authKeys: authKeys,
}, false);
}),
).subscribe();
window.addEventListener("offline", (e) => {
this.isOnline = false;
});
@ -444,14 +471,7 @@ export class AccountService {
}
getAuthKeys() {
return this.httpClient.get<AuthKey[]>(this.baseUrl + `account/auth-keys`).pipe(
tap(authKeys => {
this.setCurrentUser({
...this.currentUser!,
authKeys: authKeys,
}, false);
}),
);
return this.httpClient.get<AuthKey[]>(this.baseUrl + `account/auth-keys`);
}
createAuthKey(data: {keyLength: number, name: string, expiresUtc: string | null}) {

View File

@ -130,6 +130,14 @@ export enum EVENTS {
* Reading Session close
*/
SessionClose = 'SessionClose',
/**
* Auth key has been rotated, created
*/
AuthKeyUpdate = 'AuthKeyUpdate',
/**
* An Auth key has been deleted
*/
AuthKeyDeleted = 'AuthKeyDeleted',
}
export interface Message<T> {
@ -385,7 +393,21 @@ export class MessageHubService {
event: EVENTS.PersonMerged,
payload: resp.body
});
})
});
this.hubConnection.on(EVENTS.AuthKeyUpdate, resp => {
this.messagesSource.next({
event: EVENTS.AuthKeyUpdate,
payload: resp.body
});
});
this.hubConnection.on(EVENTS.AuthKeyDeleted, resp => {
this.messagesSource.next({
event: EVENTS.AuthKeyDeleted,
payload: resp.body
});
});
}
stopHubConnection() {

View File

@ -1,4 +1,4 @@
import { Injectable, inject } from '@angular/core';
import {inject, Injectable} from '@angular/core';
import {HttpClient, HttpParams} from "@angular/common/http";
import {environment} from "../../environments/environment";
import {Person, PersonRole} from "../_models/metadata/person";
@ -80,12 +80,6 @@ export class PersonService {
);
}
isValidAsin(asin: string) {
return this.httpClient.get<boolean>(this.baseUrl + `person/valid-asin?asin=${asin}`, TextResonse).pipe(
map(valid => valid + '' === 'true')
);
}
mergePerson(destId: number, srcId: number) {
return this.httpClient.post<Person>(this.baseUrl + 'person/merge', {destId, srcId});
}

View File

@ -24,11 +24,12 @@ import {translate} from "@jsverse/transloco";
import {ToastrService} from "ngx-toastr";
import {FilterField} from "../_models/metadata/v2/filter-field";
import {ModalService} from "./modal.service";
import {filter, map, Observable, of, switchMap, tap} from "rxjs";
import {map, of, switchMap, tap} from "rxjs";
import {ListSelectModalComponent} from "../shared/_components/list-select-modal/list-select-modal.component";
import {take, takeUntil} from "rxjs/operators";
import {SeriesService} from "./series.service";
import {Series} from "../_models/series";
import {RereadChapter, RereadPrompt} from "../_models/readers/reread-prompt";
enum RereadPromptResult {
Cancel = 0,
@ -605,116 +606,73 @@ export class ReaderService {
return parentPath ? `${parentPath}/${currentPath}` : currentPath;
}
private handleRereadPrompt(
type: 'series' | 'volume' | 'chapter',
target: Chapter,
incognitoMode: boolean,
onReread: () => Observable<any>
): Observable<{ shouldContinue: boolean; incognitoMode: boolean }> {
return this.promptForReread(type, target, incognitoMode).pipe(
switchMap(result => {
switch (result) {
case RereadPromptResult.Cancel:
return of({ shouldContinue: false, incognitoMode });
case RereadPromptResult.Continue:
return of({ shouldContinue: true, incognitoMode });
case RereadPromptResult.ReadIncognito:
return of({ shouldContinue: true, incognitoMode: true });
case RereadPromptResult.Reread:
return onReread().pipe(map(() => ({ shouldContinue: true, incognitoMode })));
}
})
);
private shouldPromptForSeriesReread(seriesId: number) {
return this.httpClient.get<RereadPrompt>(this.baseUrl + `reader/prompt-reread/series?seriesId=${seriesId}`);
}
readSeries(series: Series, incognitoMode: boolean = false, callback?: (chapter: Chapter) => void) {
const fullyRead = series.pagesRead >= series.pages;
const shouldPromptForReread = fullyRead && !incognitoMode;
private shouldPromptForVolumeReread(libraryId: number, seriesId: number, volumeId: number) {
return this.httpClient.get<RereadPrompt>(this.baseUrl + `reader/prompt-reread/volume?libraryId=${libraryId}&seriesId=${seriesId}&volumeId=${volumeId}`);
}
if (!shouldPromptForReread) {
this.getCurrentChapter(series.id).subscribe(chapter => {
this.readChapter(series.libraryId, series.id, chapter, incognitoMode);
});
return;
}
private shouldPromptForChapterReread(libraryId: number, seriesId: number, chapterId: number) {
return this.httpClient.get<RereadPrompt>(this.baseUrl + `reader/prompt-reread/chapter?libraryId=${libraryId}&seriesId=${seriesId}&chapterId=${chapterId}`);
}
this.getCurrentChapter(series.id).pipe(
switchMap(chapter =>
this.handleRereadPrompt('series', chapter, incognitoMode,
() => this.seriesService.markUnread(series.id)).pipe(
map(result => ({ chapter, result }))
)),
filter(({ result }) => result.shouldContinue),
tap(({ chapter, result }) => {
if (callback) {
callback(chapter);
}
this.readChapter(series.libraryId, series.id, chapter, result.incognitoMode, false);
})
readSeries(series: Series, incognitoMode: boolean = false) {
this.shouldPromptForSeriesReread(series.id).pipe(
switchMap(prompt => this.handlePrompt(prompt, incognitoMode)),
tap(res => this.handlePromptResult(res)),
).subscribe();
}
readVolume(libraryId: number, seriesId: number, volume: Volume, incognitoMode: boolean = false) {
if (volume.chapters.length === 0) return;
const sortedChapters = [...volume.chapters].sort(this.utilityService.sortChapters);
// No reading progress or incognito - start from first chapter
if (volume.pagesRead === 0 || incognitoMode) {
this.readChapter(libraryId, seriesId, sortedChapters[0], incognitoMode);
return;
}
// Not fully read - continue from current position
if (volume.pagesRead < volume.pages) {
const unreadChapters = volume.chapters.filter(item => item.pagesRead < item.pages);
const chapterToRead = unreadChapters.length > 0 ? unreadChapters[0] : sortedChapters[0];
this.readChapter(libraryId, seriesId, chapterToRead, incognitoMode);
return;
}
// Fully read - prompt for reread
const lastChapter = volume.chapters[volume.chapters.length - 1];
this.handleRereadPrompt('volume', lastChapter, incognitoMode,
() => this.markVolumeUnread(seriesId, volume.id)).pipe(
filter(result => result.shouldContinue),
tap(result => {
this.readChapter(libraryId, seriesId, sortedChapters[0], result.incognitoMode, false);
})
).subscribe();
this.shouldPromptForVolumeReread(libraryId, seriesId, volume.id).pipe(
switchMap(prompt => this.handlePrompt(prompt, incognitoMode)),
tap(res => this.handlePromptResult(res)),
).subscribe()
}
readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false, promptForReread: boolean = true) {
readChapter(libraryId: number, seriesId: number, chapter: Chapter, incognitoMode: boolean = false) {
if (chapter.pages === 0) {
this.toastr.error(translate('series-detail.no-pages'));
return;
}
const navigateToReader = (useIncognitoMode: boolean) => {
this.router.navigate(
this.getNavigationArray(libraryId, seriesId, chapter.id, chapter.files[0].format),
{ queryParams: { incognitoMode: useIncognitoMode } }
);
};
if (!promptForReread) {
navigateToReader(incognitoMode);
return;
}
this.handleRereadPrompt('chapter', chapter, incognitoMode,
() => this.saveProgress(libraryId, seriesId, chapter.volumeId, chapter.id, 0)
).pipe(
filter(result => result.shouldContinue),
tap(result => navigateToReader(result.incognitoMode))
).subscribe();
this.shouldPromptForChapterReread(libraryId, seriesId, chapter.id).pipe(
switchMap(prompt => this.handlePrompt(prompt, incognitoMode)),
tap(res => this.handlePromptResult(res)),
).subscribe()
}
promptForReread(entityType: 'series' | 'volume' | 'chapter', chapter: Chapter, incognitoMode: boolean) {
if (!this.shouldPromptForReread(chapter, incognitoMode)) return of(RereadPromptResult.Continue);
private handlePromptResult({prompt, result}: {prompt: RereadPrompt, result: RereadPromptResult}) {
let chapter: RereadChapter;
let useIncognitoMode = false;
const fullyRead = chapter.pagesRead >= chapter.pages;
switch (result) {
case RereadPromptResult.Cancel:
return;
case RereadPromptResult.Reread:
chapter = prompt.chapterOnReread;
break;
case RereadPromptResult.ReadIncognito:
useIncognitoMode = true;
chapter = prompt.chapterOnContinue;
break;
case RereadPromptResult.Continue:
chapter = prompt.chapterOnContinue;
break;
}
this.router.navigate(
this.getNavigationArray(chapter.libraryId, chapter.seriesId, chapter.chapterId, chapter.format),
{ queryParams: { incognitoMode: useIncognitoMode } }
).catch(err => console.error(err));
}
private handlePrompt(prompt: RereadPrompt, incognitoMode: boolean) {
if (!prompt.shouldPrompt) return of({prompt: prompt, result: RereadPromptResult.Continue});
if (incognitoMode) return of({prompt: prompt, result: RereadPromptResult.ReadIncognito});
const [modal, component] = this.modalService.open(ListSelectModalComponent, {
centered: true,
@ -723,12 +681,11 @@ export class ReaderService {
component.showFooter.set(false);
component.title.set(translate('reread-modal.title'));
const daysSinceRead = Math.round((new Date().getTime() - new Date(chapter.lastReadingProgress).getTime()) / MS_IN_DAY);
if (chapter.pagesRead >= chapter.pages) {
component.description.set(translate('reread-modal.description-full-read', { entityType: translate('entity-type.' + entityType) }));
if (prompt.timePrompt) {
component.description.set(translate('reread-modal.description-time-passed',
{ days: prompt.daysSinceLastRead, name: prompt.chapterOnReread.label }));
} else {
component.description.set(translate('reread-modal.description-time-passed', { days: daysSinceRead, entityType: translate('entity-type.' + entityType) }));
component.description.set(translate('reread-modal.description-full-read', { name: prompt.chapterOnReread.label }));
}
const options = [
@ -736,7 +693,7 @@ export class ReaderService {
{label: translate('reread-modal.continue'), value: RereadPromptResult.Continue},
];
if (fullyRead) {
if (!prompt.timePrompt) {
options.push({label: translate('reread-modal.read-incognito'), value: RereadPromptResult.ReadIncognito});
}
@ -747,22 +704,8 @@ export class ReaderService {
return modal.closed.pipe(
takeUntil(modal.dismissed),
take(1),
map(res => res as RereadPromptResult),
map(res => ({prompt: prompt, result: res as RereadPromptResult})),
);
}
private shouldPromptForReread(chapter: Chapter, incognitoMode: boolean) {
if (incognitoMode || chapter.pagesRead === 0) return false;
if (chapter.pagesRead >= chapter.pages) return true;
const userPreferences = this.accountService.currentUserSignal()!.preferences;
if (!userPreferences.promptForRereadsAfter) {
return false;
}
const daysSinceRead = (new Date().getTime() - new Date(chapter.lastReadingProgress).getTime()) / MS_IN_DAY;
return daysSinceRead > userPreferences.promptForRereadsAfter;
}
}

View File

@ -55,7 +55,7 @@ export class StatisticsService {
mangaFormatPipe = new MangaFormatPipe();
getUserStatistics(userId: number, libraryIds: Array<number> = []) {
const url = `${this.baseUrl}stats/user/${userId}/read`;
const url = `${this.baseUrl}stats/user-read?userId=${userId}`;
let params = new HttpParams();
if (libraryIds.length > 0) {
@ -65,6 +65,10 @@ export class StatisticsService {
return this.httpClient.get<UserReadStatistics>(url, { params });
}
getUserStatisticsResource(userId: () => number) {
return httpResource<UserReadStatistics>(() => this.baseUrl + `stats/user-read?userId=${userId()}`).asReadonly();
}
getServerStatistics() {
return this.httpClient.get<ServerStatistics>(this.baseUrl + 'stats/server/stats');
}

View File

@ -12,7 +12,7 @@ import {
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {Breakpoint, UtilityService} from "../../shared/_services/utility.service";
import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap";
import {ActionableEntity, ActionItem} from "../../_services/action-factory.service";
import {Action, ActionableEntity, ActionItem} from "../../_services/action-factory.service";
import {AccountService} from "../../_services/account.service";
import {tap} from "rxjs";
import {User} from "../../_models/user/user";
@ -49,6 +49,21 @@ export class ActionableModalComponent implements OnInit {
ngOnInit() {
this.currentItems = this.translateOptions(this.actions);
// On Mobile, surface download
const otherActionIndex = this.currentItems.findIndex(i => i.action === Action.Submenu && i.title === 'actionable.other')
if (otherActionIndex) {
const downloadActionIndex = this.currentItems[otherActionIndex].children.findIndex(a => a.action === Action.Download);
if (downloadActionIndex) {
const downloadAction = this.currentItems[otherActionIndex].children.splice(downloadActionIndex, 1)[0];
this.currentItems.push(downloadAction);
// Check if Other has any other children, else remove
if (this.currentItems[otherActionIndex].children.length === 0) {
this.currentItems.splice(otherActionIndex, 1);
}
}
}
this.accountService.currentUser$.pipe(tap(user => {
this.user = user;
this.cdRef.markForCheck();

View File

@ -65,7 +65,7 @@ export class AgeRatingImageComponent {
}
openRating() {
this.filterUtilityService.applyFilter(['all-series'], FilterField.AgeRating, FilterComparison.Equal, `${this.rating}`).subscribe();
this.filterUtilityService.applyFilter(['all-series'], FilterField.AgeRating, FilterComparison.Equal, `${this.rating()}`).subscribe();
}

View File

@ -620,13 +620,6 @@
</ng-template>
</li>
<!-- Progress Tab -->
<li [ngbNavItem]="TabID.Progress">
<a ngbNavLink>{{t(TabID.Progress)}}</a>
<ng-template ngbNavContent>
<app-edit-chapter-progress [chapter]="chapter" />
</ng-template>
</li>
<!-- Tasks Tab -->
<li [ngbNavItem]="TabID.Tasks">

View File

@ -22,11 +22,10 @@ import {DownloadService} from "../../shared/_services/download.service";
import {SettingItemComponent} from "../../settings/_components/setting-item/setting-item.component";
import {TypeaheadComponent} from "../../typeahead/_components/typeahead.component";
import {concat, forkJoin, Observable, of, tap} from "rxjs";
import {map, switchMap} from "rxjs/operators";
import {map} from "rxjs/operators";
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {MangaFormat} from "../../_models/manga-format";
@ -46,7 +45,6 @@ enum TabID {
Info = 'info-tab',
People = 'people-tab',
Tasks = 'tasks-tab',
Progress = 'progress-tab',
Tags = 'tags-tab'
}
@ -62,33 +60,32 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
@Component({
selector: 'app-edit-chapter-modal',
imports: [
FormsModule,
NgbNav,
NgbNavContent,
NgbNavLink,
TranslocoDirective,
AsyncPipe,
NgbNavOutlet,
ReactiveFormsModule,
NgbNavItem,
SettingItemComponent,
NgTemplateOutlet,
NgClass,
TypeaheadComponent,
EntityTitleComponent,
TitleCasePipe,
SettingButtonComponent,
CoverImageChooserComponent,
EditChapterProgressComponent,
CompactNumberPipe,
DefaultDatePipe,
UtcToLocalTimePipe,
BytesPipe,
ImageComponent,
SafeHtmlPipe,
ReadTimePipe,
],
imports: [
FormsModule,
NgbNav,
NgbNavContent,
NgbNavLink,
TranslocoDirective,
AsyncPipe,
NgbNavOutlet,
ReactiveFormsModule,
NgbNavItem,
SettingItemComponent,
NgTemplateOutlet,
NgClass,
TypeaheadComponent,
EntityTitleComponent,
TitleCasePipe,
SettingButtonComponent,
CoverImageChooserComponent,
CompactNumberPipe,
DefaultDatePipe,
UtcToLocalTimePipe,
BytesPipe,
ImageComponent,
SafeHtmlPipe,
ReadTimePipe,
],
templateUrl: './edit-chapter-modal.component.html',
styleUrl: './edit-chapter-modal.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush

View File

@ -105,18 +105,6 @@
</li>
}
<!-- Progress Tab -->
<li [ngbNavItem]="TabID.Progress">
<a ngbNavLink>{{t(TabID.Progress)}}</a>
<ng-template ngbNavContent>
@for(chapter of volume.chapters; track chapter.id) {
<h6><app-entity-title [entity]="chapter" [prioritizeTitleName]="false" /></h6>
<app-edit-chapter-progress [chapter]="chapter" />
<div class="setting-section-break"></div>
}
</ng-template>
</li>
<!-- Tasks Tab -->
<li [ngbNavItem]="TabID.Tasks">
<a ngbNavLink>{{t(TabID.Tasks)}}</a>

View File

@ -7,7 +7,6 @@ import {SettingItemComponent} from "../../settings/_components/setting-item/sett
import {EntityTitleComponent} from "../../cards/entity-title/entity-title.component";
import {SettingButtonComponent} from "../../settings/_components/setting-button/setting-button.component";
import {CoverImageChooserComponent} from "../../cards/cover-image-chooser/cover-image-chooser.component";
import {EditChapterProgressComponent} from "../../cards/edit-chapter-progress/edit-chapter-progress.component";
import {CompactNumberPipe} from "../../_pipes/compact-number.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
@ -63,7 +62,6 @@ const blackList = [Action.Edit, Action.IncognitoRead, Action.AddToReadingList];
EntityTitleComponent,
SettingButtonComponent,
CoverImageChooserComponent,
EditChapterProgressComponent,
CompactNumberPipe,
DefaultDatePipe,
UtcToLocalTimePipe,

View File

@ -28,113 +28,207 @@
</form>
</div>
<ngx-datatable
class="bootstrap"
[rows]="events"
[columnMode]="ColumnMode.force"
(sort)="updateSort($event)"
(page)="onPageChange($event)"
rowHeight="auto"
[footerHeight]="50"
[externalPaging]="true"
[count]="pageInfo.totalElements"
[offset]="pageInfo.pageNumber"
[limit]="pageInfo.size"
[sorts]="[{prop: 'createdUtc', dir: 'desc'}]"
>
<app-responsive-table [rows]="events" [trackByFn]="trackByEvents">
<ngx-datatable
class="bootstrap"
[rows]="events"
[columnMode]="ColumnMode.force"
(sort)="updateSort($event)"
(page)="onPageChange($event)"
rowHeight="auto"
[footerHeight]="50"
[externalPaging]="true"
[count]="pageInfo.totalElements"
[offset]="pageInfo.pageNumber"
[limit]="pageInfo.size"
[sorts]="[{prop: 'createdUtc', dir: 'desc'}]"
>
<ngx-datatable-column prop="select" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="selections.hasSomeSelected()">
<label for="select-all" class="form-check-label d-md-block d-none">{{t('select-all-label')}}</label>
</div>
</ng-template>
<ng-template let-event="row" let-idx="index" ngx-datatable-cell-template>
<input id="select-event-{{idx}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(event)" (change)="handleSelection(event, idx)">
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="select" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
<div class="form-check">
<input id="select-all" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="selections.hasSomeSelected()">
<label for="select-all" class="form-check-label d-md-block d-none">{{t('select-all-label')}}</label>
</div>
</ng-template>
<ng-template let-event="row" let-idx="index" ngx-datatable-cell-template>
<input id="select-event-{{idx}}" type="checkbox" class="form-check-input"
[ngModel]="selections.isSelected(event)" (change)="handleSelection(event, idx)">
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('type-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | scrobbleEventType}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="scrobbleEventType" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('type-header')}}
</ng-template>
<ng-template let-value="value" ngx-datatable-cell-template>
{{value | scrobbleEventType}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<a href="{{baseUrl}}library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank" id="scrobble-history--{{idx}}">{{item.seriesName}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="seriesName" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
<a href="{{baseUrl}}library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank" id="scrobble-history--{{idx}}">{{item.seriesName}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="data" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('data-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
<ngx-datatable-column prop="data" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('data-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
@switch (item.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(item.volumeNumber === LooseLeafOrDefaultNumber) {
@if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: item.volumeNumber})}}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@else if (item.chapterNumber === LooseLeafOrDefaultNumber && item.volumeNumber === SpecialVolumeNumber) {
Special
}
@else {
{{t('volume-and-chapter-num', {v: item.volumeNumber, n: item.chapterNumber})}}
@default {
{{t('not-applicable')}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: item.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
</ng-template>
</ngx-datatable-column>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="isProcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('is-processed-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
<ngx-datatable-column prop="isProcessed" [sortable]="true" [draggable]="false" [resizeable]="false">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('is-processed-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
@if(item.isProcessed) {
<i class="fa-solid fa-check-circle icon" aria-hidden="true"></i>
} @else if (item.isErrored) {
<i class="fa-solid fa-circle-exclamation icon error" aria-hidden="true" [ngbTooltip]="item.errorDetails"></i>
} @else {
<i class="fa-regular fa-circle icon" aria-hidden="true"></i>
}
<span class="visually-hidden" attr.aria-labelledby="scrobble-history--{{idx}}">
{{item.isProcessed ? t('processed') : t('not-processed')}}
</span>
</ng-template>
</ngx-datatable-column>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
</ngx-datatable>
<div cardHeader class="card mb-3">
<div class="card-body py-2">
<div class="form-check">
<input id="select-all-cards" type="checkbox" class="form-check-input"
[ngModel]="selectAll" (change)="toggleAll()" [indeterminate]="selections.hasSomeSelected()">
<label for="select-all-cards" class="form-check-label">{{t('select-all-label')}}</label>
</div>
</div>
</div>
<ng-template #cardTemplate let-event let-idx="index">
<div class="card mb-3">
<div class="card-body">
<!-- Header with checkbox and series name -->
<div class="d-flex align-items-start mb-3">
<h5 class="card-title mb-0">
<a [href]="baseUrl + 'library/' + event.libraryId + '/series/' + event.seriesId"
target="_blank"
[id]="'scrobble-history--' + idx">
{{event.seriesName}}
</a>
</h5>
</div>
<!-- Event details -->
<div class="row g-2">
<!-- Created date -->
<div class="col-6">
<div class="text-muted small">{{t('created-header')}}</div>
<div>{{event.createdUtc | utcToLocalTime | defaultValue}}</div>
</div>
<!-- Event type -->
<div class="col-6">
<div class="text-muted small">{{t('type-header')}}</div>
<div>{{event.scrobbleEventType | scrobbleEventType}}</div>
</div>
<!-- Data -->
<div class="col-12">
<div class="text-muted small">{{t('data-header')}}</div>
<div>
@switch (event.scrobbleEventType) {
@case (ScrobbleEventType.ChapterRead) {
@if(event.volumeNumber === LooseLeafOrDefaultNumber) {
@if (event.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('special')}}
} @else {
{{t('chapter-num', {num: event.chapterNumber})}}
}
}
@else if (event.chapterNumber === LooseLeafOrDefaultNumber) {
{{t('volume-num', {num: event.volumeNumber})}}
}
@else if (event.chapterNumber === LooseLeafOrDefaultNumber && event.volumeNumber === SpecialVolumeNumber) {
{{t('special')}}
}
@else {
{{t('volume-and-chapter-num', {v: event.volumeNumber, n: event.chapterNumber})}}
}
}
@case (ScrobbleEventType.ScoreUpdated) {
{{t('rating', {r: event.rating})}}
}
@default {
{{t('not-applicable')}}
}
}
</div>
</div>
<!-- Status -->
<div class="col-12">
<div class="text-muted small">{{t('is-processed-header')}}</div>
<div>
@if(event.isProcessed) {
<i class="fa-solid fa-check-circle text-success" aria-hidden="true"></i>
<span class="ms-1">{{t('processed')}}</span>
} @else if (event.isErrored) {
<i class="fa-solid fa-circle-exclamation text-danger" aria-hidden="true"></i>
<span class="ms-1" [ngbTooltip]="event.errorDetails">{{t('error')}}</span>
} @else {
<i class="fa-regular fa-circle text-muted" aria-hidden="true"></i>
<span class="ms-1">{{t('not-processed')}}</span>
}
</div>
</div>
</div>
</div>
</div>
</ng-template>
</app-responsive-table>
</ng-container>

View File

@ -28,6 +28,7 @@ import {APP_BASE_HREF, AsyncPipe} from "@angular/common";
import {AccountService} from "../../_services/account.service";
import {ToastrService} from "ngx-toastr";
import {SelectionModel} from "../../typeahead/_models/selection-model";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
export interface DataTablePage {
pageNumber: number,
@ -39,7 +40,7 @@ export interface DataTablePage {
@Component({
selector: 'app-user-scrobble-history',
imports: [ScrobbleEventTypePipe, ReactiveFormsModule, TranslocoModule,
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe, FormsModule],
DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgbTooltip, NgxDatatableModule, AsyncPipe, FormsModule, ResponsiveTableComponent],
templateUrl: './user-scrobble-history.component.html',
styleUrls: ['./user-scrobble-history.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -81,6 +82,8 @@ export class UserScrobbleHistoryComponent implements OnInit {
isShiftDown: boolean = false;
lastSelectedIndex: number | null = null;
trackByEvents = (idx: number, data: ScrobbleEvent) => `${data.isProcessed}_${data.isErrored}_${data.id}`;
@HostListener('document:keydown.shift', ['$event'])
handleKeypress(_: KeyboardEvent) {
this.isShiftDown = true;

View File

@ -105,6 +105,7 @@
<app-setting-multi-check-box
id="roles"
[title]="t('roles-label')"
[warning]="(readOnlyWarning$ | async) ?? undefined"
[options]="roleOptions"
formControlName="roles"
/>

View File

@ -18,7 +18,7 @@ import {AccountService, allRoles, Role} from 'src/app/_services/account.service'
import {SentenceCasePipe} from '../../_pipes/sentence-case.pipe';
import {RestrictionSelectorComponent} from '../../user-settings/restriction-selector/restriction-selector.component';
import {AsyncPipe} from '@angular/common';
import {TranslocoDirective} from "@jsverse/transloco";
import {translate, TranslocoDirective} from "@jsverse/transloco";
import {debounceTime, distinctUntilChanged, Observable, startWith, tap} from "rxjs";
import {map} from "rxjs/operators";
import {takeUntilDestroyed} from "@angular/core/rxjs-interop";
@ -73,6 +73,7 @@ export class EditUserComponent implements OnInit {
userForm: FormGroup = new FormGroup({});
isEmailInvalid$!: Observable<boolean>;
readOnlyWarning$!: Observable<string | undefined>;
allowedCharacters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._@+/';
@ -109,6 +110,14 @@ export class EditUserComponent implements OnInit {
map(value => !EmailRegex.test(value)),
takeUntilDestroyed(this.destroyRef)
);
this.readOnlyWarning$ = this.userForm.get('roles')!.valueChanges.pipe(
startWith(this.member().roles),
takeUntilDestroyed(this.destroyRef),
distinctUntilChanged(),
debounceTime(10),
map((roles: string[]) => roles.includes(Role.ReadOnly)),
map(readOnlySelected => readOnlySelected ? translate('edit-user.warning-read-only') : undefined),
);
this.selectedRestriction = this.member().ageRestriction;
this.cdRef.markForCheck();

View File

@ -1,7 +1,8 @@
<ng-container *transloco="let t; prefix:'email-history'">
<p>{{t('description')}}</p>
<ngx-datatable
<app-responsive-table [rows]="data" [trackByFn]="trackBy">
<ngx-datatable
class="bootstrap"
[rows]="data"
[columnMode]="ColumnMode.force"
@ -54,4 +55,37 @@
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ng-template #cardTemplate let-item let-idx="index">
<div class="card mb-3">
<div class="card-body">
<!-- Header with template name and status -->
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0">{{item.emailTemplate}}</h5>
<div>
@if (item.sent) {
<i class="fa-solid fa-check-circle text-success" aria-hidden="true"></i>
<span class="visually-hidden">{{t('sent-tooltip')}}</span>
} @else {
<i class="fa-solid fa-exclamation-circle text-danger" aria-hidden="true"></i>
<span class="visually-hidden">{{t('not-sent-tooltip')}}</span>
}
</div>
</div>
<!-- Details -->
<div class="row g-2">
<div class="col-6">
<div class="text-muted small">{{t('date-header')}}</div>
<div>{{item.sendDate | utcToLocalTime}}</div>
</div>
<div class="col-6">
<div class="text-muted small">{{t('user-header')}}</div>
<div>{{item.toUserName}}</div>
</div>
</div>
</div>
</div>
</ng-template>
</app-responsive-table>
</ng-container>

View File

@ -5,6 +5,7 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {EmailHistory} from "../../_models/email-history";
import {EmailService} from "../../_services/email.service";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
@Component({
selector: 'app-email-history',
@ -12,7 +13,8 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
TranslocoDirective,
VirtualScrollerModule,
UtcToLocalTimePipe,
NgxDatatableModule
NgxDatatableModule,
ResponsiveTableComponent
],
templateUrl: './email-history.component.html',
styleUrl: './email-history.component.scss',
@ -25,6 +27,8 @@ export class EmailHistoryComponent implements OnInit {
isLoading = true;
data: Array<EmailHistory> = [];
trackBy = (index: number, item: EmailHistory) => `${item.sent}_${item.emailTemplate}_${index}`;
ngOnInit() {
this.emailService.getEmailHistory().subscribe(data => {
this.data = data;

View File

@ -23,6 +23,7 @@
</div>
</form>
<app-responsive-table [rows]="data" [trackByFn]="trackBy">
<ngx-datatable
class="bootstrap"
[rows]="data"
@ -100,4 +101,68 @@
</ngx-datatable-column>
</ngx-datatable>
<ng-template #cardTemplate let-item let-idx="index">
<div class="card mb-3">
<div class="card-body">
<!-- Header with cover, series name and action button -->
<div class="d-flex align-items-start mb-3">
<app-image
[width]="'48px'"
[height]="'48px'"
[imageUrl]="imageService.getSeriesCoverImage(item.series.id)"
class="me-3" />
<div class="flex-grow-1">
<h5 class="card-title mb-1">
<a [href]="baseUrl + 'library/' + item.series.libraryId + '/series/' + item.series.id"
target="_blank">
{{item.series.name}}
</a>
</h5>
<div class="text-muted small">
{{item.series.libraryId | libraryName | async}}
</div>
</div>
<button class="btn btn-primary btn-sm" (click)="fixMatch(item.series)">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('match-alt', {seriesName: item.series.name})}}</span>
</button>
</div>
<!-- Details -->
<div class="row g-2">
<div class="col-6">
<div class="text-muted small">{{t('status-header')}}</div>
<div>
@if (item.series.isBlacklisted) {
{{t('blacklist-status-label')}}
} @else if (item.series.dontMatch) {
{{t('dont-match-status-label')}}
} @else {
@if (item.isMatched) {
{{t('matched-status-label')}}
} @else {
{{t('unmatched-status-label')}}
}
}
</div>
</div>
@if (filterGroup.get('matchState')?.value === MatchStateOption.Matched) {
<div class="col-6">
<div class="text-muted small">{{t('valid-until-header')}}</div>
<div>
@if (item.series.isBlacklisted || item.series.dontMatch || !item.isMatched) {
{{null | defaultValue}}
} @else {
{{item.validUntilUtc | utcToLocalTime}}
}
</div>
</div>
}
</div>
</div>
</div>
</ng-template>
</app-responsive-table>
</ng-container>

View File

@ -25,6 +25,7 @@ import {LibraryTypePipe} from "../../_pipes/library-type.pipe";
import {allKavitaPlusMetadataApplicableTypes} from "../../_models/library/library";
import {ExternalMatchRateLimitErrorEvent} from "../../_models/events/external-match-rate-limit-error-event";
import {ToastrService} from "ngx-toastr";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
@Component({
selector: 'app-manage-matched-metadata',
@ -40,6 +41,7 @@ import {ToastrService} from "ngx-toastr";
LibraryNamePipe,
AsyncPipe,
LibraryTypePipe,
ResponsiveTableComponent,
],
templateUrl: './manage-matched-metadata.component.html',
styleUrl: './manage-matched-metadata.component.scss',
@ -68,6 +70,7 @@ export class ManageMatchedMetadataComponent implements OnInit {
'matchState': new FormControl(MatchStateOption.Error, []),
'libraryType': new FormControl(-1, []), // Denotes all
});
trackBy = (idx: number, item: ManageMatchSeries) => `${item.isMatched}_${item.series.name}_${idx}`;
ngOnInit() {
this.licenseService.hasValidLicense$.subscribe(license => {

View File

@ -12,41 +12,65 @@
</div>
</div>
</form>
<ngx-datatable
class="bootstrap"
[rows]="data | filter: filterList"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column prop="filePath" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('file-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.filePath}}
</ng-template>
</ngx-datatable-column>
@let filteredRows = data | filter: filterList;
<app-responsive-table [rows]="filteredRows" [trackByFn]="trackBy">
<ngx-datatable
class="bootstrap"
[rows]="filteredRows"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column prop="filePath" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('file-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.filePath}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="comment" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.comment}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="comment" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.comment}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.createdUtc | utcToLocalTime | defaultDate}}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ngx-datatable-column prop="createdUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.createdUtc | utcToLocalTime | defaultDate}}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ng-template #cardTemplate let-item let-idx="index">
<div class="card mb-3">
<div class="card-body">
<h6 class="card-title mb-3 text-break">{{item.filePath}}</h6>
<div class="row g-2">
<div class="col-12">
<div class="text-muted small">{{t('comment-header')}}</div>
<div>{{item.comment}}</div>
</div>
<div class="col-12">
<div class="text-muted small">{{t('created-header')}}</div>
<div>{{item.createdUtc | utcToLocalTime | defaultDate}}</div>
</div>
</div>
</div>
</div>
</ng-template>
</app-responsive-table>
</ng-container>

View File

@ -23,12 +23,13 @@ import {WikiLink} from "../../_models/wiki";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {DefaultDatePipe} from "../../_pipes/default-date.pipe";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
@Component({
selector: 'app-manage-media-issues',
templateUrl: './manage-media-issues.component.html',
styleUrls: ['./manage-media-issues.component.scss'],
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule],
imports: [ReactiveFormsModule, FilterPipe, TranslocoDirective, UtcToLocalTimePipe, DefaultDatePipe, NgxDatatableModule, ResponsiveTableComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ManageMediaIssuesComponent implements OnInit {
@ -53,6 +54,7 @@ export class ManageMediaIssuesComponent implements OnInit {
formGroup = new FormGroup({
filter: new FormControl('', [])
});
trackBy = (idx: number, item: KavitaMediaError) => `${item.filePath}`
ngOnInit(): void {

View File

@ -14,54 +14,86 @@
</div>
</form>
@let filteredData = data | filter: filterList;
<app-responsive-table [rows]="filteredData" [trackByFn]="trackBy">
<ngx-datatable
class="bootstrap"
[rows]="filteredData"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable
class="bootstrap"
[rows]="data | filter: filterList"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column prop="seriesId" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="seriesId" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('series-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">{{item.details}}</a>
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="created" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.created | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="created" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('created-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.created | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="comment" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.comment}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="comment" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('comment-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.comment}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column name="edit" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('edit-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<button class="btn btn-icon" (click)="fixMatch(item.seriesId)">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('match-alt', {seriesName: item.details})}}</span>
</button>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ngx-datatable-column name="edit" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('edit-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
<button class="btn btn-icon" (click)="fixMatch(item.seriesId)">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('match-alt', {seriesName: item.details})}}</span>
</button>
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ng-template #cardTemplate let-item let-idx="index">
<div class="card mb-3">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start mb-3">
<h5 class="card-title mb-0">
<a href="library/{{item.libraryId}}/series/{{item.seriesId}}" target="_blank">
{{item.details}}
</a>
</h5>
<button class="btn btn-primary btn-sm" (click)="fixMatch(item.seriesId)">
<i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i>
<span class="visually-hidden">{{t('match-alt', {seriesName: item.details})}}</span>
</button>
</div>
<div class="row g-2">
<div class="col-6">
<div class="text-muted small">{{t('created-header')}}</div>
<div>{{item.created | utcToLocalTime | defaultValue}}</div>
</div>
<div class="col-6">
<div class="text-muted small">{{t('comment-header')}}</div>
<div>{{item.comment}}</div>
</div>
</div>
</div>
</div>
</ng-template>
</app-responsive-table>
</ng-container>

View File

@ -27,10 +27,11 @@ import {TranslocoLocaleModule} from "@jsverse/transloco-locale";
import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {ActionService} from "../../_services/action.service";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
@Component({
selector: 'app-manage-scrobble-errors',
imports: [ReactiveFormsModule, FilterPipe, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgxDatatableModule],
imports: [ReactiveFormsModule, FilterPipe, TranslocoModule, DefaultValuePipe, TranslocoLocaleModule, UtcToLocalTimePipe, NgxDatatableModule, ResponsiveTableComponent],
templateUrl: './manage-scrobble-errors.component.html',
styleUrls: ['./manage-scrobble-errors.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
@ -60,6 +61,7 @@ export class ManageScrobbleErrorsComponent implements OnInit {
formGroup = new FormGroup({
filter: new FormControl('', [])
});
trackBy = (index: number, item: ScrobbleError) => `${item.seriesId}`;
ngOnInit() {

View File

@ -151,43 +151,64 @@
<div class="setting-section-break"></div>
<h4>{{t('recurring-tasks-title')}}</h4>
<ngx-datatable
class="bootstrap"
[rows]="recurringTasks$ | async"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column prop="title" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('job-title-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.title | titlecase}}
</ng-template>
</ngx-datatable-column>
@let data = (recurringTasks$ | async) ?? [];
<app-responsive-table [rows]="data" [trackByFn]="trackBy">
<ngx-datatable
class="bootstrap"
[rows]="data"
[columnMode]="ColumnMode.flex"
rowHeight="auto"
[footerHeight]="50"
[limit]="15"
>
<ngx-datatable-column prop="title" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="3">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('job-title-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.title | titlecase}}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="lastExecutionUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('last-executed-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.lastExecutionUtc | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="lastExecutionUtc" [sortable]="true" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('last-executed-header')}}
</ng-template>
<ng-template let-item="row" let-idx="index" ngx-datatable-cell-template>
{{item.lastExecutionUtc | utcToLocalTime | defaultValue }}
</ng-template>
</ngx-datatable-column>
<ngx-datatable-column prop="cron" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('cron-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.cron}}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ngx-datatable-column prop="cron" [sortable]="false" [draggable]="false" [resizeable]="false" [flexGrow]="1">
<ng-template let-column="column" ngx-datatable-header-template>
{{t('cron-header')}}
</ng-template>
<ng-template let-item="row" ngx-datatable-cell-template>
{{item.cron}}
</ng-template>
</ngx-datatable-column>
</ngx-datatable>
<ng-template #cardTemplate let-item let-idx="index">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">{{item.title | titlecase}}</h5>
<div class="row g-2">
<div class="col-6">
<div class="text-muted small">{{t('last-executed-header')}}</div>
<div>{{item.lastExecutionUtc | utcToLocalTime | defaultValue}}</div>
</div>
<div class="col-6">
<div class="text-muted small">{{t('cron-header')}}</div>
<div>{{item.cron}}</div>
</div>
</div>
</div>
</div>
</ng-template>
</app-responsive-table>
</form>
}
</ng-container>

View File

@ -35,6 +35,7 @@ import {SettingButtonComponent} from "../../settings/_components/setting-button/
import {DefaultModalOptions} from "../../_models/default-modal-options";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {AnnotationService} from "../../_services/annotation.service";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
interface AdhocTask {
name: string;
@ -49,9 +50,9 @@ interface AdhocTask {
templateUrl: './manage-tasks-settings.component.html',
styleUrls: ['./manage-tasks-settings.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent,
SettingButtonComponent, NgxDatatableModule]
imports: [ReactiveFormsModule, AsyncPipe, TitleCasePipe, DefaultValuePipe,
TranslocoModule, TranslocoLocaleModule, UtcToLocalTimePipe, SettingItemComponent,
SettingButtonComponent, NgxDatatableModule, ResponsiveTableComponent]
})
export class ManageTasksSettingsComponent implements OnInit {
@ -143,6 +144,7 @@ export class ManageTasksSettingsComponent implements OnInit {
];
customOption = 'custom';
trackBy = (index: number, item: Job) => `${item.id}_${item.lastExecutionUtc}`;
ngOnInit(): void {

View File

@ -1,8 +1,8 @@
<ng-container *transloco="let t; prefix:'manage-user-tokens'">
<p>{{t('description')}}</p>
<ngx-datatable
<app-responsive-table [rows]="users" [trackByFn]="trackBy">
<ngx-datatable
class="bootstrap"
[rows]="users"
[columnMode]="ColumnMode.force"
@ -45,5 +45,40 @@
}
</ng-template>
</ngx-datatable-column>
<ng-template #cardTemplate let-item let-idx="index">
<div class="card mb-3">
<div class="card-body">
<h5 class="card-title mb-3">{{item.username}}</h5>
<div class="row g-2">
<div class="col-12">
<div class="text-muted small">{{t('anilist-header')}}</div>
<div>
@if (item.isAniListTokenSet) {
{{t('token-set-label')}}
<span class="text-muted ms-1">{{t('expires-label', {date: item.aniListValidUntilUtc | utcToLocalTime})}}</span>
} @else {
{{null | defaultValue}}
}
</div>
</div>
<div class="col-12">
<div class="text-muted small">{{t('mal-header')}}</div>
<div>
@if (item.isMalTokenSet) {
{{t('token-set-label')}}
} @else {
{{null | defaultValue}}
}
</div>
</div>
</div>
</div>
</div>
</ng-template>
</ngx-datatable>
</app-responsive-table>
</ng-container>

View File

@ -6,6 +6,7 @@ import {UtcToLocalTimePipe} from "../../_pipes/utc-to-local-time.pipe";
import {VirtualScrollerModule} from "@iharbeck/ngx-virtual-scroller";
import {UserTokenInfo} from "../../_models/kavitaplus/user-token-info";
import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
import {ResponsiveTableComponent} from "../../shared/_components/responsive-table/responsive-table.component";
@Component({
selector: 'app-manage-user-tokens',
@ -14,7 +15,8 @@ import {ColumnMode, NgxDatatableModule} from "@siemens/ngx-datatable";
DefaultValuePipe,
UtcToLocalTimePipe,
VirtualScrollerModule,
NgxDatatableModule
NgxDatatableModule,
ResponsiveTableComponent
],
templateUrl: './manage-user-tokens.component.html',
styleUrl: './manage-user-tokens.component.scss',
@ -27,6 +29,7 @@ export class ManageUserTokensComponent implements OnInit {
isLoading = true;
users: UserTokenInfo[] = [];
trackBy = (idx: number, item: UserTokenInfo) => item;
ngOnInit() {
this.loadData();

Some files were not shown because too many files have changed in this diff Show More