Personal Table of Contents (#2148)
* Fixed a bad default setting for token key * Changed the payment link to support Google Pay * Fixed duplicate events occurring on newly added series from a scan. Fixed the version update code from not firing and made it check every 4-6 hours (random per user per restart) * Check for new releases on startup as well. Added Personal Table of Contents (called Bookmarks on epub and pdf reader). The idea is that sometimes you want to bookmark certain parts of pages to get back to quickly later. This mechanism will allow you to do that without having to edit the underlying ToC. * Added a button to update modal to show how to update for those unaware. * Darkened the link text within tables to be more visible. * Update link for how to update now is dynamic for docker users * Refactored to send proper star/end dates for scrobble read events for upcoming changes in the API. Added GoogleBooks Rating UI code if I go forward with API changes. * When Scrobbling, send when the first and last progress for the series was. Added OpenLibrary icon for upcoming enhancements for Kavita+. Changed the Update checker to execute at start. * Fixed backups not saving favicons in the correct place * Refactored the layout code for Personal ToC * More bugfixes around toc * Box alignment * Fixed up closing the overlay when bookmark mode is active * Fixed up closing the overlay when bookmark mode is active --------- Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
This commit is contained in:
parent
f3b8074b3a
commit
a0a6da9c60
53 changed files with 3538 additions and 244 deletions
|
@ -158,5 +158,4 @@ public class BookController : BaseApiController
|
|||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -792,4 +792,59 @@ public class ReaderController : BaseApiController
|
|||
return _readerService.GetTimeEstimate(0, pagesLeft, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the user's personal table of contents for the given chapter
|
||||
/// </summary>
|
||||
/// <param name="chapterId"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet("ptoc")]
|
||||
public ActionResult<IEnumerable<PersonalToCDto>> GetPersonalToC(int chapterId)
|
||||
{
|
||||
return Ok(_unitOfWork.UserTableOfContentRepository.GetPersonalToC(User.GetUserId(), chapterId));
|
||||
}
|
||||
|
||||
[HttpDelete("ptoc")]
|
||||
public async Task<ActionResult> DeletePersonalToc([FromQuery] int chapterId, [FromQuery] int pageNum, [FromQuery] string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title)) return BadRequest("Name cannot be empty");
|
||||
if (pageNum < 0) return BadRequest("Must be valid page number");
|
||||
var toc = await _unitOfWork.UserTableOfContentRepository.Get(User.GetUserId(), chapterId, pageNum, title);
|
||||
if (toc == null) return Ok();
|
||||
_unitOfWork.UserTableOfContentRepository.Remove(toc);
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a new personal table of content entry for a given chapter
|
||||
/// </summary>
|
||||
/// <remarks>The title and page number must be unique to that book</remarks>
|
||||
/// <param name="dto"></param>
|
||||
/// <returns></returns>
|
||||
[HttpPost("create-ptoc")]
|
||||
public async Task<ActionResult> CreatePersonalToC(CreatePersonalToCDto dto)
|
||||
{
|
||||
// Validate there isn't already an existing page title combo?
|
||||
if (string.IsNullOrWhiteSpace(dto.Title)) return BadRequest("Name cannot be empty");
|
||||
if (dto.PageNumber < 0) return BadRequest("Must be valid page number");
|
||||
var userId = User.GetUserId();
|
||||
if (await _unitOfWork.UserTableOfContentRepository.IsUnique(userId, dto.ChapterId, dto.PageNumber,
|
||||
dto.Title.Trim()))
|
||||
{
|
||||
return BadRequest("Duplicate ToC entry already exists");
|
||||
}
|
||||
|
||||
_unitOfWork.UserTableOfContentRepository.Attach(new AppUserTableOfContent()
|
||||
{
|
||||
Title = dto.Title.Trim(),
|
||||
ChapterId = dto.ChapterId,
|
||||
PageNumber = dto.PageNumber,
|
||||
SeriesId = dto.SeriesId,
|
||||
LibraryId = dto.LibraryId,
|
||||
BookScrollId = dto.BookScrollId,
|
||||
AppUserId = userId
|
||||
});
|
||||
await _unitOfWork.CommitAsync();
|
||||
return Ok();
|
||||
}
|
||||
}
|
||||
|
|
12
API/DTOs/Reader/CreatePersonalToCDto.cs
Normal file
12
API/DTOs/Reader/CreatePersonalToCDto.cs
Normal file
|
@ -0,0 +1,12 @@
|
|||
namespace API.DTOs.Reader;
|
||||
|
||||
public class CreatePersonalToCDto
|
||||
{
|
||||
public required int ChapterId { get; set; }
|
||||
public required int VolumeId { get; set; }
|
||||
public required int SeriesId { get; set; }
|
||||
public required int LibraryId { get; set; }
|
||||
public required int PageNumber { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? BookScrollId { get; set; }
|
||||
}
|
9
API/DTOs/Reader/PersonalToCDto.cs
Normal file
9
API/DTOs/Reader/PersonalToCDto.cs
Normal file
|
@ -0,0 +1,9 @@
|
|||
namespace API.DTOs.Reader;
|
||||
|
||||
public class PersonalToCDto
|
||||
{
|
||||
public required int ChapterId { get; set; }
|
||||
public required int PageNumber { get; set; }
|
||||
public required string Title { get; set; }
|
||||
public string? BookScrollId { get; set; }
|
||||
}
|
|
@ -66,8 +66,16 @@ public class ScrobbleDto
|
|||
/// </summary>
|
||||
public DateTime? StartedReadingDateUtc { get; set; }
|
||||
/// <summary>
|
||||
/// The latest date the series was read. Will be null for non ReadingProgress events
|
||||
/// </summary>
|
||||
public DateTime? LatestReadingDateUtc { get; set; }
|
||||
/// <summary>
|
||||
/// The date that the series was scrobbled. Will be null for non ReadingProgress events
|
||||
/// </summary>
|
||||
public DateTime? ScrobbleDateUtc { get; set; }
|
||||
/// <summary>
|
||||
/// Optional but can help with matching
|
||||
/// </summary>
|
||||
public string? Isbn { get; set; }
|
||||
|
||||
}
|
||||
|
|
|
@ -10,9 +10,9 @@ public class ScrobbleEventDto
|
|||
public bool IsProcessed { get; set; }
|
||||
public int? VolumeNumber { get; set; }
|
||||
public int? ChapterNumber { get; set; }
|
||||
public DateTime? ProcessDateUtc { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime Created { get; set; }
|
||||
public float? Rating { get; set; }
|
||||
public ScrobbleEventType ScrobbleEventType { get; set; }
|
||||
|
||||
}
|
||||
|
|
|
@ -53,6 +53,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
|
|||
public DbSet<ScrobbleError> ScrobbleError { get; set; } = null!;
|
||||
public DbSet<ScrobbleHold> ScrobbleHold { get; set; } = null!;
|
||||
public DbSet<AppUserOnDeckRemoval> AppUserOnDeckRemoval { get; set; } = null!;
|
||||
public DbSet<AppUserTableOfContent> AppUserTableOfContent { get; set; } = null!;
|
||||
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
|
|
2266
API/Data/Migrations/20230719173458_PersonalToC.Designer.cs
generated
Normal file
2266
API/Data/Migrations/20230719173458_PersonalToC.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
79
API/Data/Migrations/20230719173458_PersonalToC.cs
Normal file
79
API/Data/Migrations/20230719173458_PersonalToC.cs
Normal file
|
@ -0,0 +1,79 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace API.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class PersonalToC : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AppUserTableOfContent",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
PageNumber = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
LibraryId = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
BookScrollId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Created = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModified = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
LastModifiedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AppUserTableOfContent", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserTableOfContent_AspNetUsers_AppUserId",
|
||||
column: x => x.AppUserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserTableOfContent_Chapter_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapter",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AppUserTableOfContent_Series_SeriesId",
|
||||
column: x => x.SeriesId,
|
||||
principalTable: "Series",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserTableOfContent_AppUserId",
|
||||
table: "AppUserTableOfContent",
|
||||
column: "AppUserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserTableOfContent_ChapterId",
|
||||
table: "AppUserTableOfContent",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AppUserTableOfContent_SeriesId",
|
||||
table: "AppUserTableOfContent",
|
||||
column: "SeriesId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AppUserTableOfContent");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -180,7 +180,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("AppUserBookmark");
|
||||
b.ToTable("AppUserBookmark", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserOnDeckRemoval", b =>
|
||||
|
@ -201,7 +201,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserOnDeckRemoval");
|
||||
b.ToTable("AppUserOnDeckRemoval", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
|
||||
|
@ -309,7 +309,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ThemeId");
|
||||
|
||||
b.ToTable("AppUserPreferences");
|
||||
b.ToTable("AppUserPreferences", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
|
||||
|
@ -359,7 +359,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserProgresses");
|
||||
b.ToTable("AppUserProgresses", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRating", b =>
|
||||
|
@ -389,7 +389,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserRating");
|
||||
b.ToTable("AppUserRating", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserRole", b =>
|
||||
|
@ -407,6 +407,59 @@ namespace API.Data.Migrations
|
|||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("AppUserId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("BookScrollId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ChapterId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime>("Created")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("CreatedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModified")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("LastModifiedUtc")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LibraryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("PageNumber")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("VolumeId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("AppUserTableOfContent", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
|
@ -514,7 +567,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("Chapter");
|
||||
b.ToTable("Chapter", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.CollectionTag", b =>
|
||||
|
@ -549,7 +602,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("Id", "Promoted")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("CollectionTag");
|
||||
b.ToTable("CollectionTag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Device", b =>
|
||||
|
@ -595,7 +648,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("Device");
|
||||
b.ToTable("Device", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.FolderPath", b =>
|
||||
|
@ -617,7 +670,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("FolderPath");
|
||||
b.ToTable("FolderPath", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Genre", b =>
|
||||
|
@ -637,7 +690,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Genre");
|
||||
b.ToTable("Genre", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Library", b =>
|
||||
|
@ -695,7 +748,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Library");
|
||||
b.ToTable("Library", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MangaFile", b =>
|
||||
|
@ -744,7 +797,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.ToTable("MangaFile");
|
||||
b.ToTable("MangaFile", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.MediaError", b =>
|
||||
|
@ -779,7 +832,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("MediaError");
|
||||
b.ToTable("MediaError", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesMetadata", b =>
|
||||
|
@ -880,7 +933,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("Id", "SeriesId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("SeriesMetadata");
|
||||
b.ToTable("SeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Metadata.SeriesRelation", b =>
|
||||
|
@ -904,7 +957,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("TargetSeriesId");
|
||||
|
||||
b.ToTable("SeriesRelation");
|
||||
b.ToTable("SeriesRelation", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Person", b =>
|
||||
|
@ -924,7 +977,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Person");
|
||||
b.ToTable("Person", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingList", b =>
|
||||
|
@ -987,7 +1040,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("AppUserId");
|
||||
|
||||
b.ToTable("ReadingList");
|
||||
b.ToTable("ReadingList", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ReadingListItem", b =>
|
||||
|
@ -1021,7 +1074,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("VolumeId");
|
||||
|
||||
b.ToTable("ReadingListItem");
|
||||
b.ToTable("ReadingListItem", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleError", b =>
|
||||
|
@ -1066,7 +1119,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleError");
|
||||
b.ToTable("ScrobbleError", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleEvent", b =>
|
||||
|
@ -1137,7 +1190,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleEvent");
|
||||
b.ToTable("ScrobbleEvent", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Scrobble.ScrobbleHold", b =>
|
||||
|
@ -1170,7 +1223,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("ScrobbleHold");
|
||||
b.ToTable("ScrobbleHold", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Series", b =>
|
||||
|
@ -1266,7 +1319,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibraryId");
|
||||
|
||||
b.ToTable("Series");
|
||||
b.ToTable("Series", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerSetting", b =>
|
||||
|
@ -1283,7 +1336,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Key");
|
||||
|
||||
b.ToTable("ServerSetting");
|
||||
b.ToTable("ServerSetting", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.ServerStatistics", b =>
|
||||
|
@ -1321,7 +1374,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("ServerStatistics");
|
||||
b.ToTable("ServerStatistics", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.SiteTheme", b =>
|
||||
|
@ -1359,7 +1412,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("SiteTheme");
|
||||
b.ToTable("SiteTheme", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Tag", b =>
|
||||
|
@ -1379,7 +1432,7 @@ namespace API.Data.Migrations
|
|||
b.HasIndex("NormalizedTitle")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tag");
|
||||
b.ToTable("Tag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Volume", b =>
|
||||
|
@ -1431,7 +1484,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("Volume");
|
||||
b.ToTable("Volume", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("AppUserLibrary", b =>
|
||||
|
@ -1446,7 +1499,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("LibrariesId");
|
||||
|
||||
b.ToTable("AppUserLibrary");
|
||||
b.ToTable("AppUserLibrary", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterGenre", b =>
|
||||
|
@ -1461,7 +1514,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("GenresId");
|
||||
|
||||
b.ToTable("ChapterGenre");
|
||||
b.ToTable("ChapterGenre", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterPerson", b =>
|
||||
|
@ -1476,7 +1529,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("PeopleId");
|
||||
|
||||
b.ToTable("ChapterPerson");
|
||||
b.ToTable("ChapterPerson", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
|
@ -1491,7 +1544,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTag");
|
||||
b.ToTable("ChapterTag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
|
||||
|
@ -1506,7 +1559,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("CollectionTagSeriesMetadata");
|
||||
b.ToTable("CollectionTagSeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GenreSeriesMetadata", b =>
|
||||
|
@ -1521,7 +1574,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("GenreSeriesMetadata");
|
||||
b.ToTable("GenreSeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
|
||||
|
@ -1620,7 +1673,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("SeriesMetadatasId");
|
||||
|
||||
b.ToTable("PersonSeriesMetadata");
|
||||
b.ToTable("PersonSeriesMetadata", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("SeriesMetadataTag", b =>
|
||||
|
@ -1635,7 +1688,7 @@ namespace API.Data.Migrations
|
|||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("SeriesMetadataTag");
|
||||
b.ToTable("SeriesMetadataTag", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
|
||||
|
@ -1746,6 +1799,33 @@ namespace API.Data.Migrations
|
|||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.AppUserTableOfContent", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.AppUser", "AppUser")
|
||||
.WithMany("TableOfContents")
|
||||
.HasForeignKey("AppUserId")
|
||||
.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.Navigation("AppUser");
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("API.Entities.Chapter", b =>
|
||||
{
|
||||
b.HasOne("API.Entities.Volume", "Volume")
|
||||
|
@ -2130,6 +2210,8 @@ namespace API.Data.Migrations
|
|||
|
||||
b.Navigation("ScrobbleHolds");
|
||||
|
||||
b.Navigation("TableOfContents");
|
||||
|
||||
b.Navigation("UserPreferences");
|
||||
|
||||
b.Navigation("UserRoles");
|
||||
|
|
|
@ -31,6 +31,8 @@ public interface IAppUserProgressRepository
|
|||
Task<bool> AnyUserProgressForSeriesAsync(int seriesId, int userId);
|
||||
Task<int> GetHighestFullyReadChapterForSeries(int seriesId, int userId);
|
||||
Task<int> GetHighestFullyReadVolumeForSeries(int seriesId, int userId);
|
||||
Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId);
|
||||
Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId);
|
||||
}
|
||||
#nullable disable
|
||||
public class AppUserProgressRepository : IAppUserProgressRepository
|
||||
|
@ -179,7 +181,23 @@ public class AppUserProgressRepository : IAppUserProgressRepository
|
|||
return list.Count == 0 ? 0 : list.DefaultIfEmpty().Max();
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
public async Task<DateTime?> GetLatestProgressForSeries(int seriesId, int userId)
|
||||
{
|
||||
var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.SeriesId == seriesId)
|
||||
.Select(p => p.LastModifiedUtc)
|
||||
.ToListAsync();
|
||||
return list.Count == 0 ? null : list.DefaultIfEmpty().Max();
|
||||
}
|
||||
|
||||
public async Task<DateTime?> GetFirstProgressForSeries(int seriesId, int userId)
|
||||
{
|
||||
var list = await _context.AppUserProgresses.Where(p => p.AppUserId == userId && p.SeriesId == seriesId)
|
||||
.Select(p => p.LastModifiedUtc)
|
||||
.ToListAsync();
|
||||
return list.Count == 0 ? null : list.DefaultIfEmpty().Min();
|
||||
}
|
||||
|
||||
#nullable enable
|
||||
public async Task<AppUserProgress?> GetUserProgressAsync(int chapterId, int userId)
|
||||
{
|
||||
return await _context.AppUserProgresses
|
||||
|
|
64
API/Data/Repositories/UserTableOfContentRepository.cs
Normal file
64
API/Data/Repositories/UserTableOfContentRepository.cs
Normal file
|
@ -0,0 +1,64 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace API.Data.Repositories;
|
||||
#nullable enable
|
||||
|
||||
public interface IUserTableOfContentRepository
|
||||
{
|
||||
void Attach(AppUserTableOfContent toc);
|
||||
void Remove(AppUserTableOfContent toc);
|
||||
Task<bool> IsUnique(int userId, int chapterId, int page, string title);
|
||||
IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId);
|
||||
Task<AppUserTableOfContent?> Get(int userId, int chapterId, int pageNum, string title);
|
||||
}
|
||||
|
||||
public class UserTableOfContentRepository : IUserTableOfContentRepository
|
||||
{
|
||||
private readonly DataContext _context;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public UserTableOfContentRepository(DataContext context, IMapper mapper)
|
||||
{
|
||||
_context = context;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public void Attach(AppUserTableOfContent toc)
|
||||
{
|
||||
_context.AppUserTableOfContent.Attach(toc);
|
||||
}
|
||||
|
||||
public void Remove(AppUserTableOfContent toc)
|
||||
{
|
||||
_context.AppUserTableOfContent.Remove(toc);
|
||||
}
|
||||
|
||||
public async Task<bool> IsUnique(int userId, int chapterId, int page, string title)
|
||||
{
|
||||
return await _context.AppUserTableOfContent.AnyAsync(t =>
|
||||
t.AppUserId == userId && t.PageNumber == page && t.Title == title && t.ChapterId == chapterId);
|
||||
}
|
||||
|
||||
public IEnumerable<PersonalToCDto> GetPersonalToC(int userId, int chapterId)
|
||||
{
|
||||
return _context.AppUserTableOfContent
|
||||
.Where(t => t.AppUserId == userId && t.ChapterId == chapterId)
|
||||
.ProjectTo<PersonalToCDto>(_mapper.ConfigurationProvider)
|
||||
.OrderBy(t => t.PageNumber)
|
||||
.AsEnumerable();
|
||||
}
|
||||
|
||||
public async Task<AppUserTableOfContent?> Get(int userId,int chapterId, int pageNum, string title)
|
||||
{
|
||||
return await _context.AppUserTableOfContent
|
||||
.Where(t => t.AppUserId == userId && t.ChapterId == chapterId && t.PageNumber == pageNum && t.Title == title)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
}
|
|
@ -27,6 +27,7 @@ public interface IUnitOfWork
|
|||
IDeviceRepository DeviceRepository { get; }
|
||||
IMediaErrorRepository MediaErrorRepository { get; }
|
||||
IScrobbleRepository ScrobbleRepository { get; }
|
||||
IUserTableOfContentRepository UserTableOfContentRepository { get; }
|
||||
bool Commit();
|
||||
Task<bool> CommitAsync();
|
||||
bool HasChanges();
|
||||
|
@ -66,6 +67,7 @@ public class UnitOfWork : IUnitOfWork
|
|||
public IDeviceRepository DeviceRepository => new DeviceRepository(_context, _mapper);
|
||||
public IMediaErrorRepository MediaErrorRepository => new MediaErrorRepository(_context, _mapper);
|
||||
public IScrobbleRepository ScrobbleRepository => new ScrobbleRepository(_context, _mapper);
|
||||
public IUserTableOfContentRepository UserTableOfContentRepository => new UserTableOfContentRepository(_context, _mapper);
|
||||
|
||||
/// <summary>
|
||||
/// Commits changes to the DB. Completes the open transaction.
|
||||
|
|
|
@ -37,9 +37,9 @@ public class AppUser : IdentityUser<int>, IHasConcurrencyToken
|
|||
/// </summary>
|
||||
public ICollection<Device> Devices { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// A list of Series the user doesn't want on deck
|
||||
/// A list of Table of Contents for a given Chapter
|
||||
/// </summary>
|
||||
//public ICollection<Series> OnDeckRemovals { get; set; } = null!;
|
||||
public ICollection<AppUserTableOfContent> TableOfContents { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// An API Key to interact with external services, like OPDS
|
||||
/// </summary>
|
||||
|
|
49
API/Entities/AppUserTableOfContent.cs
Normal file
49
API/Entities/AppUserTableOfContent.cs
Normal file
|
@ -0,0 +1,49 @@
|
|||
using System;
|
||||
using API.Entities.Interfaces;
|
||||
|
||||
namespace API.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// A personal table of contents for a given user linked with a given book
|
||||
/// </summary>
|
||||
public class AppUserTableOfContent : IEntityDate
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The page to bookmark
|
||||
/// </summary>
|
||||
public required int PageNumber { get; set; }
|
||||
/// <summary>
|
||||
/// The title of the bookmark. Defaults to Page {PageNumber} if not set
|
||||
/// </summary>
|
||||
public required string Title { get; set; }
|
||||
|
||||
public required int SeriesId { get; set; }
|
||||
public virtual Series Series { get; set; }
|
||||
|
||||
public required int ChapterId { get; set; }
|
||||
public virtual Chapter Chapter { get; set; }
|
||||
|
||||
public int VolumeId { get; set; }
|
||||
public int LibraryId { get; set; }
|
||||
/// <summary>
|
||||
/// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point. If empty, the ToC point is the beginning of the page
|
||||
/// </summary>
|
||||
public string? BookScrollId { get; set; }
|
||||
|
||||
public DateTime Created { get; set; }
|
||||
public DateTime CreatedUtc { get; set; }
|
||||
public DateTime LastModified { get; set; }
|
||||
public DateTime LastModifiedUtc { get; set; }
|
||||
|
||||
// Relationships
|
||||
/// <summary>
|
||||
/// Navigational Property for EF. Links to a unique AppUser
|
||||
/// </summary>
|
||||
public AppUser AppUser { get; set; } = null!;
|
||||
/// <summary>
|
||||
/// User this table of content belongs to
|
||||
/// </summary>
|
||||
public int AppUserId { get; set; }
|
||||
}
|
|
@ -46,6 +46,7 @@ public class ScrobbleEvent : IEntityDate
|
|||
/// </summary>
|
||||
public DateTime? ProcessDateUtc { get; set; }
|
||||
|
||||
|
||||
public required int SeriesId { get; set; }
|
||||
public Series Series { get; set; }
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using API.Data.Migrations;
|
||||
using API.DTOs;
|
||||
using API.DTOs.Account;
|
||||
using API.DTOs.CollectionTags;
|
||||
|
@ -19,6 +20,10 @@ using API.Entities.Metadata;
|
|||
using API.Entities.Scrobble;
|
||||
using API.Helpers.Converters;
|
||||
using AutoMapper;
|
||||
using CollectionTag = API.Entities.CollectionTag;
|
||||
using MediaError = API.Entities.MediaError;
|
||||
using PublicationStatus = API.Entities.Enums.PublicationStatus;
|
||||
using SiteTheme = API.Entities.SiteTheme;
|
||||
|
||||
namespace API.Helpers;
|
||||
|
||||
|
@ -211,6 +216,7 @@ public class AutoMapperProfiles : Profile
|
|||
.ConvertUsing<ServerSettingConverter>();
|
||||
|
||||
CreateMap<Device, DeviceDto>();
|
||||
CreateMap<AppUserTableOfContent, PersonalToCDto>();
|
||||
|
||||
}
|
||||
}
|
||||
|
|
36
API/Helpers/Builders/PlusSeriesDtoBuilder.cs
Normal file
36
API/Helpers/Builders/PlusSeriesDtoBuilder.cs
Normal file
|
@ -0,0 +1,36 @@
|
|||
using System.Linq;
|
||||
using API.DTOs;
|
||||
using API.Entities;
|
||||
using API.Services.Plus;
|
||||
|
||||
namespace API.Helpers.Builders;
|
||||
|
||||
public class PlusSeriesDtoBuilder : IEntityBuilder<PlusSeriesDto>
|
||||
{
|
||||
private readonly PlusSeriesDto _seriesDto;
|
||||
public PlusSeriesDto Build() => _seriesDto;
|
||||
|
||||
/// <summary>
|
||||
/// This must be a FULL Series
|
||||
/// </summary>
|
||||
/// <param name="series"></param>
|
||||
public PlusSeriesDtoBuilder(Series series)
|
||||
{
|
||||
_seriesDto = new PlusSeriesDto()
|
||||
{
|
||||
MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
SeriesName = series.Name,
|
||||
AltSeriesName = series.LocalizedName,
|
||||
AniListId = ScrobblingService.ExtractId<int?>(series.Metadata.WebLinks,
|
||||
ScrobblingService.AniListWeblinkWebsite),
|
||||
MalId = ScrobblingService.ExtractId<long?>(series.Metadata.WebLinks,
|
||||
ScrobblingService.MalWeblinkWebsite),
|
||||
GoogleBooksId = ScrobblingService.ExtractId<string?>(series.Metadata.WebLinks,
|
||||
ScrobblingService.GoogleBooksWeblinkWebsite),
|
||||
VolumeCount = series.Volumes.Count,
|
||||
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
|
||||
Year = series.Metadata.ReleaseYear
|
||||
};
|
||||
}
|
||||
|
||||
}
|
|
@ -6,7 +6,6 @@ using System.Linq;
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using System.Web;
|
||||
using API.Data.Metadata;
|
||||
using API.DTOs.Reader;
|
||||
using API.Entities;
|
||||
|
@ -898,7 +897,7 @@ public class BookService : IBookService
|
|||
/// <param name="mappings">Epub mappings</param>
|
||||
/// <param name="page">Page number we are loading</param>
|
||||
/// <returns></returns>
|
||||
public async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page)
|
||||
private async Task<string> ScopePage(HtmlDocument doc, EpubBookRef book, string apiBase, HtmlNode body, Dictionary<string, int> mappings, int page)
|
||||
{
|
||||
await InlineStyles(doc, book, apiBase, body);
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ public class StartupTasksHostedService : IHostedService
|
|||
taskScheduler.ScheduleUpdaterTasks();
|
||||
|
||||
|
||||
|
||||
try
|
||||
{
|
||||
// These methods will automatically check if stat collection is disabled to prevent sending any data regardless
|
||||
|
|
|
@ -9,6 +9,7 @@ using API.DTOs;
|
|||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
using Kavita.Common.EnvironmentInfo;
|
||||
|
@ -59,19 +60,7 @@ public class RatingService : IRatingService
|
|||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.PostJsonAsync(new PlusSeriesDto()
|
||||
{
|
||||
MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
SeriesName = series.Name,
|
||||
AltSeriesName = series.LocalizedName,
|
||||
AniListId = (int?) ScrobblingService.ExtractId(series.Metadata.WebLinks,
|
||||
ScrobblingService.AniListWeblinkWebsite),
|
||||
MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks,
|
||||
ScrobblingService.MalWeblinkWebsite),
|
||||
VolumeCount = series.Volumes.Count,
|
||||
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
|
||||
Year = series.Metadata.ReleaseYear
|
||||
})
|
||||
.PostJsonAsync(new PlusSeriesDtoBuilder(series).Build())
|
||||
.ReceiveJson<IEnumerable<RatingDto>>();
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
|
@ -11,6 +11,7 @@ using API.Entities;
|
|||
using API.Entities.Enums;
|
||||
using API.Extensions;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Tasks.Scanner.Parser;
|
||||
using Flurl.Http;
|
||||
using Kavita.Common;
|
||||
|
@ -24,6 +25,7 @@ public record PlusSeriesDto
|
|||
{
|
||||
public int? AniListId { get; set; }
|
||||
public long? MalId { get; set; }
|
||||
public string? GoogleBooksId { get; set; }
|
||||
public string SeriesName { get; set; }
|
||||
public string? AltSeriesName { get; set; }
|
||||
public MediaFormat MediaFormat { get; set; }
|
||||
|
@ -134,19 +136,7 @@ public class RecommendationService : IRecommendationService
|
|||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.PostJsonAsync(new PlusSeriesDto()
|
||||
{
|
||||
MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
SeriesName = series.Name,
|
||||
AltSeriesName = series.LocalizedName,
|
||||
AniListId = (int?) ScrobblingService.ExtractId(series.Metadata.WebLinks,
|
||||
ScrobblingService.AniListWeblinkWebsite),
|
||||
MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks,
|
||||
ScrobblingService.MalWeblinkWebsite),
|
||||
VolumeCount = series.Volumes.Count,
|
||||
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
|
||||
Year = series.Metadata.ReleaseYear
|
||||
})
|
||||
.PostJsonAsync(new PlusSeriesDtoBuilder(series).Build())
|
||||
.ReceiveJson<IEnumerable<MediaRecommendationDto>>();
|
||||
|
||||
}
|
||||
|
|
|
@ -63,11 +63,14 @@ public class ScrobblingService : IScrobblingService
|
|||
|
||||
public const string AniListWeblinkWebsite = "https://anilist.co/manga/";
|
||||
public const string MalWeblinkWebsite = "https://myanimelist.net/manga/";
|
||||
public const string GoogleBooksWeblinkWebsite = "https://books.google.com/books?id=";
|
||||
|
||||
private static readonly IDictionary<string, int> WeblinkExtractionMap = new Dictionary<string, int>()
|
||||
{
|
||||
{AniListWeblinkWebsite, 0},
|
||||
{MalWeblinkWebsite, 0},
|
||||
{GoogleBooksWeblinkWebsite, 0},
|
||||
|
||||
};
|
||||
|
||||
private const int ScrobbleSleepTime = 700; // We can likely tie this to AniList's 90 rate / min ((60 * 1000) / 90)
|
||||
|
@ -208,8 +211,8 @@ public class ScrobblingService : IScrobblingService
|
|||
SeriesId = series.Id,
|
||||
LibraryId = series.LibraryId,
|
||||
ScrobbleEventType = ScrobbleEventType.Review,
|
||||
AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AppUserId = userId,
|
||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
ReviewBody = reviewBody,
|
||||
|
@ -253,8 +256,8 @@ public class ScrobblingService : IScrobblingService
|
|||
SeriesId = series.Id,
|
||||
LibraryId = series.LibraryId,
|
||||
ScrobbleEventType = ScrobbleEventType.ScoreUpdated,
|
||||
AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AppUserId = userId,
|
||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
Rating = rating
|
||||
|
@ -310,8 +313,8 @@ public class ScrobblingService : IScrobblingService
|
|||
SeriesId = series.Id,
|
||||
LibraryId = series.LibraryId,
|
||||
ScrobbleEventType = ScrobbleEventType.ChapterRead,
|
||||
AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AppUserId = userId,
|
||||
VolumeNumber =
|
||||
await _unitOfWork.AppUserProgressRepository.GetHighestFullyReadVolumeForSeries(seriesId, userId),
|
||||
|
@ -353,8 +356,8 @@ public class ScrobblingService : IScrobblingService
|
|||
SeriesId = series.Id,
|
||||
LibraryId = series.LibraryId,
|
||||
ScrobbleEventType = onWantToRead ? ScrobbleEventType.AddWantToRead : ScrobbleEventType.RemoveWantToRead,
|
||||
AniListId = (int?) ExtractId(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AniListId = ExtractId<int?>(series.Metadata.WebLinks, AniListWeblinkWebsite),
|
||||
MalId = ExtractId<long?>(series.Metadata.WebLinks, MalWeblinkWebsite),
|
||||
AppUserId = userId,
|
||||
Format = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
};
|
||||
|
@ -542,7 +545,7 @@ public class ScrobblingService : IScrobblingService
|
|||
[AutomaticRetry(Attempts = 3, OnAttemptsExceeded = AttemptsExceededAction.Delete)]
|
||||
public async Task ProcessUpdatesSinceLastSync()
|
||||
{
|
||||
// Check how many scrobbles we have available then only do those.
|
||||
// Check how many scrobble events we have available then only do those.
|
||||
_logger.LogInformation("Starting Scrobble Processing");
|
||||
var userRateLimits = new Dictionary<int, int>();
|
||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
|
@ -623,7 +626,7 @@ public class ScrobblingService : IScrobblingService
|
|||
readEvt.AppUser.Id);
|
||||
_unitOfWork.ScrobbleRepository.Update(readEvt);
|
||||
}
|
||||
progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto()
|
||||
progressCounter = await ProcessEvents(readEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, async evt => new ScrobbleDto()
|
||||
{
|
||||
Format = evt.Format,
|
||||
AniListId = evt.AniListId,
|
||||
|
@ -634,12 +637,14 @@ public class ScrobblingService : IScrobblingService
|
|||
AniListToken = evt.AppUser.AniListAccessToken,
|
||||
SeriesName = evt.Series.Name,
|
||||
LocalizedSeriesName = evt.Series.LocalizedName,
|
||||
StartedReadingDateUtc = evt.CreatedUtc,
|
||||
ScrobbleDateUtc = evt.LastModifiedUtc,
|
||||
Year = evt.Series.Metadata.ReleaseYear
|
||||
Year = evt.Series.Metadata.ReleaseYear,
|
||||
StartedReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetFirstProgressForSeries(evt.SeriesId, evt.AppUser.Id),
|
||||
LatestReadingDateUtc = await _unitOfWork.AppUserProgressRepository.GetLatestProgressForSeries(evt.SeriesId, evt.AppUser.Id),
|
||||
});
|
||||
|
||||
progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto()
|
||||
progressCounter = await ProcessEvents(ratingEvents, userRateLimits, usersToScrobble.Count, progressCounter,
|
||||
totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
||||
{
|
||||
Format = evt.Format,
|
||||
AniListId = evt.AniListId,
|
||||
|
@ -650,9 +655,10 @@ public class ScrobblingService : IScrobblingService
|
|||
LocalizedSeriesName = evt.Series.LocalizedName,
|
||||
Rating = evt.Rating,
|
||||
Year = evt.Series.Metadata.ReleaseYear
|
||||
});
|
||||
}));
|
||||
|
||||
progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto()
|
||||
progressCounter = await ProcessEvents(reviewEvents, userRateLimits, usersToScrobble.Count, progressCounter,
|
||||
totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
||||
{
|
||||
Format = evt.Format,
|
||||
AniListId = evt.AniListId,
|
||||
|
@ -665,21 +671,22 @@ public class ScrobblingService : IScrobblingService
|
|||
Year = evt.Series.Metadata.ReleaseYear,
|
||||
ReviewBody = evt.ReviewBody,
|
||||
ReviewTitle = evt.ReviewTitle
|
||||
});
|
||||
}));
|
||||
|
||||
progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter, totalProgress, evt => new ScrobbleDto()
|
||||
{
|
||||
Format = evt.Format,
|
||||
AniListId = evt.AniListId,
|
||||
MALId = (int?) evt.MalId,
|
||||
ScrobbleEventType = evt.ScrobbleEventType,
|
||||
ChapterNumber = evt.ChapterNumber,
|
||||
VolumeNumber = evt.VolumeNumber,
|
||||
AniListToken = evt.AppUser.AniListAccessToken,
|
||||
SeriesName = evt.Series.Name,
|
||||
LocalizedSeriesName = evt.Series.LocalizedName,
|
||||
Year = evt.Series.Metadata.ReleaseYear
|
||||
});
|
||||
progressCounter = await ProcessEvents(decisions, userRateLimits, usersToScrobble.Count, progressCounter,
|
||||
totalProgress, evt => Task.FromResult(new ScrobbleDto()
|
||||
{
|
||||
Format = evt.Format,
|
||||
AniListId = evt.AniListId,
|
||||
MALId = (int?) evt.MalId,
|
||||
ScrobbleEventType = evt.ScrobbleEventType,
|
||||
ChapterNumber = evt.ChapterNumber,
|
||||
VolumeNumber = evt.VolumeNumber,
|
||||
AniListToken = evt.AppUser.AniListAccessToken,
|
||||
SeriesName = evt.Series.Name,
|
||||
LocalizedSeriesName = evt.Series.LocalizedName,
|
||||
Year = evt.Series.Metadata.ReleaseYear
|
||||
}));
|
||||
}
|
||||
catch (FlurlHttpException)
|
||||
{
|
||||
|
@ -693,7 +700,7 @@ public class ScrobblingService : IScrobblingService
|
|||
}
|
||||
|
||||
private async Task<int> ProcessEvents(IEnumerable<ScrobbleEvent> events, IDictionary<int, int> userRateLimits,
|
||||
int usersToScrobble, int progressCounter, int totalProgress, Func<ScrobbleEvent, ScrobbleDto> createEvent)
|
||||
int usersToScrobble, int progressCounter, int totalProgress, Func<ScrobbleEvent, Task<ScrobbleDto>> createEvent)
|
||||
{
|
||||
var license = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.LicenseKey);
|
||||
foreach (var evt in events)
|
||||
|
@ -714,7 +721,7 @@ public class ScrobblingService : IScrobblingService
|
|||
|
||||
try
|
||||
{
|
||||
var data = createEvent(evt);
|
||||
var data = await createEvent(evt);
|
||||
userRateLimits[evt.AppUserId] = await PostScrobbleUpdate(data, license.Value, evt);
|
||||
evt.IsProcessed = true;
|
||||
evt.ProcessDateUtc = DateTime.UtcNow;
|
||||
|
@ -784,17 +791,31 @@ public class ScrobblingService : IScrobblingService
|
|||
/// <param name="webLinks"></param>
|
||||
/// <param name="website"></param>
|
||||
/// <returns></returns>
|
||||
public static long? ExtractId(string webLinks, string website)
|
||||
public static T? ExtractId<T>(string webLinks, string website)
|
||||
{
|
||||
var index = WeblinkExtractionMap[website];
|
||||
foreach (var webLink in webLinks.Split(','))
|
||||
{
|
||||
if (!webLink.StartsWith(website)) continue;
|
||||
var tokens = webLink.Split(website)[1].Split('/');
|
||||
return long.Parse(tokens[index]);
|
||||
var value = tokens[index];
|
||||
if (typeof(T) == typeof(int))
|
||||
{
|
||||
if (int.TryParse(value, out var intValue))
|
||||
return (T)(object)intValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(long))
|
||||
{
|
||||
if (long.TryParse(value, out var longValue))
|
||||
return (T)(object)longValue;
|
||||
}
|
||||
else if (typeof(T) == typeof(string))
|
||||
{
|
||||
return (T)(object)value;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
return default(T?);
|
||||
}
|
||||
|
||||
private async Task<int> SetAndCheckRateLimit(IDictionary<int, int> userRateLimits, AppUser user, string license)
|
||||
|
|
|
@ -9,6 +9,7 @@ using API.DTOs.SeriesDetail;
|
|||
using API.Entities;
|
||||
using API.Entities.Enums;
|
||||
using API.Helpers;
|
||||
using API.Helpers.Builders;
|
||||
using API.Services.Plus;
|
||||
using Flurl.Http;
|
||||
using HtmlAgilityPack;
|
||||
|
@ -133,19 +134,7 @@ public class ReviewService : IReviewService
|
|||
.WithHeader("x-kavita-version", BuildInfo.Version)
|
||||
.WithHeader("Content-Type", "application/json")
|
||||
.WithTimeout(TimeSpan.FromSeconds(Configuration.DefaultTimeOutSecs))
|
||||
.PostJsonAsync(new PlusSeriesDto()
|
||||
{
|
||||
MediaFormat = LibraryTypeHelper.GetFormat(series.Library.Type),
|
||||
SeriesName = series.Name,
|
||||
AltSeriesName = series.LocalizedName,
|
||||
AniListId = (int?) ScrobblingService.ExtractId(series.Metadata.WebLinks,
|
||||
ScrobblingService.AniListWeblinkWebsite),
|
||||
MalId = ScrobblingService.ExtractId(series.Metadata.WebLinks,
|
||||
ScrobblingService.MalWeblinkWebsite),
|
||||
VolumeCount = series.Volumes.Count,
|
||||
ChapterCount = series.Volumes.SelectMany(v => v.Chapters).Count(c => !c.IsSpecial),
|
||||
Year = series.Metadata.ReleaseYear
|
||||
})
|
||||
.PostJsonAsync(new PlusSeriesDtoBuilder(series).Build())
|
||||
.ReceiveJson<IEnumerable<MediaReviewDto>>();
|
||||
|
||||
}
|
||||
|
|
|
@ -61,6 +61,7 @@ public class TaskScheduler : ITaskScheduler
|
|||
public const string DefaultQueue = "default";
|
||||
public const string RemoveFromWantToReadTaskId = "remove-from-want-to-read";
|
||||
public const string UpdateYearlyStatsTaskId = "update-yearly-stats";
|
||||
public const string CheckForUpdateId = "check-updates";
|
||||
public const string CleanupDbTaskId = "cleanup-db";
|
||||
public const string CleanupTaskId = "cleanup";
|
||||
public const string BackupTaskId = "backup";
|
||||
|
@ -226,10 +227,8 @@ public class TaskScheduler : ITaskScheduler
|
|||
public void ScheduleUpdaterTasks()
|
||||
{
|
||||
_logger.LogInformation("Scheduling Auto-Update tasks");
|
||||
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Daily(Rnd.Next(5, 23)), new RecurringJobOptions()
|
||||
{
|
||||
TimeZone = TimeZoneInfo.Local
|
||||
});
|
||||
RecurringJob.AddOrUpdate(CheckForUpdateId, () => CheckForUpdate(), $"0 */{Rnd.Next(4, 6)} * * *", RecurringJobOptions);
|
||||
BackgroundJob.Enqueue(() => CheckForUpdate());
|
||||
}
|
||||
|
||||
public void ScanFolder(string folderPath, TimeSpan delay)
|
||||
|
|
|
@ -145,7 +145,7 @@ public class BackupService : IBackupService
|
|||
|
||||
private void CopyFaviconsToBackupDirectory(string tempDirectory)
|
||||
{
|
||||
_directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, tempDirectory);
|
||||
_directoryService.CopyDirectoryToDirectory(_directoryService.FaviconDirectory, _directoryService.FileSystem.Path.Join(tempDirectory, "favicons"));
|
||||
}
|
||||
|
||||
private async Task CopyCoverImagesToBackupDirectory(string tempDirectory)
|
||||
|
|
|
@ -72,13 +72,11 @@ public class VersionUpdaterService : IVersionUpdaterService
|
|||
/// <summary>
|
||||
/// Fetches the latest release from Github
|
||||
/// </summary>
|
||||
/// <returns>Latest update or null if current version is greater than latest update</returns>
|
||||
public async Task<UpdateNotificationDto?> CheckForUpdate()
|
||||
/// <returns>Latest update</returns>
|
||||
public async Task<UpdateNotificationDto> CheckForUpdate()
|
||||
{
|
||||
var update = await GetGithubRelease();
|
||||
var dto = CreateDto(update);
|
||||
if (dto == null) return null;
|
||||
return new Version(dto.UpdateVersion) <= new Version(dto.CurrentVersion) ? null : dto;
|
||||
return CreateDto(update);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<UpdateNotificationDto>> GetAllReleases()
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"TokenKey": "super secret unguessable key",
|
||||
"TokenKey": "super secret unguessable key that is longer because we require it",
|
||||
"Port": 5000,
|
||||
"IpAddresses": "",
|
||||
"BaseUrl": "/",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue