mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-01-06 12:10:20 -05:00
No more JWTs for Scripts + Polish (#4274)
Co-authored-by: Amelia <77553571+Fesaa@users.noreply.github.com>
This commit is contained in:
parent
b67680c639
commit
8043650aa5
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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]});
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
}
|
||||
|
||||
@ -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"));
|
||||
|
||||
|
||||
194
API/Controllers/DeprecatedController.cs
Normal file
194
API/Controllers/DeprecatedController.cs
Normal 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"));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@ -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_";
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ using MimeTypes;
|
||||
namespace API.Controllers;
|
||||
#nullable enable
|
||||
|
||||
[AllowAnonymous]
|
||||
[Authorize]
|
||||
public class OpdsController : BaseApiController
|
||||
{
|
||||
private readonly IOpdsService _opdsService;
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"));
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
37
API/DTOs/Reader/ReReadDto.cs
Normal file
37
API/DTOs/Reader/ReReadDto.cs
Normal 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);
|
||||
@ -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; }
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
4426
API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.Designer.cs
generated
Normal file
4426
API/Data/Migrations/20251210145923_BookmarkRelationshipAndSearchIndex.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 =>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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 =>
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
125
API/Middleware/AuthKeyAuthenticationHandler.cs
Normal file
125
API/Middleware/AuthKeyAuthenticationHandler.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
26
API/Services/Caching/AuthKeyCacheInvalidator.cs
Normal file
26
API/Services/Caching/AuthKeyCacheInvalidator.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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}";
|
||||
}
|
||||
|
||||
|
||||
272
API/Services/EntityDisplayService.cs
Normal file
272
API/Services/EntityDisplayService.cs
Normal 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
|
||||
};
|
||||
}
|
||||
@ -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>>();
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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());
|
||||
|
||||
18
UI/Web/src/app/_models/readers/reread-prompt.ts
Normal file
18
UI/Web/src/app/_models/readers/reread-prompt.ts
Normal 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,
|
||||
}
|
||||
@ -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}) {
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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});
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -105,6 +105,7 @@
|
||||
<app-setting-multi-check-box
|
||||
id="roles"
|
||||
[title]="t('roles-label')"
|
||||
[warning]="(readOnlyWarning$ | async) ?? undefined"
|
||||
[options]="roleOptions"
|
||||
formControlName="roles"
|
||||
/>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user