v0.4.4 - Release (#520)

* Adding automated docker version tag for stable releases

- Added set to parse version to x.x.x format for tag. (This is the same way we parse for sentry release maps)

- Added additional build and push step for docker with parse version as tag.

* Bugfix/reader progress (#435)

Fixed: Fixed an issue where marking a series as Unread when the DB gets skewed with duplicate progress rows for that item, would break. Now we cleanup any extra rows we see during the operation.

* Feature/manual db backup (#437)

Added: New button in manage server to manually create a backup of DB (Fixes Add on demand DB backup from Admin Dashboard #322)

* Performance/cache epub (#438)

Changed: (Performance) Added the ability for epubs to cache, allowing faster page load for users with network mounted storage. (Fixes Investigate caching epubs (benefit for network mounted users) #433 )

* Ignore Mac metadata hidden files ._* (#443)

* Implemented the ability to completely ignore all MacOS ._ files. They are ignored for all I/O operations (on all OSes)

* Filtering First Pass (#442)

# Added
- Added: Added "In Progress" view to see everything you are currently reading
- Added: Added the ability to filter series based on format from "In Progress", "Recently Added", "Library Detail" pages.
- Added: Added total items to the above pages to showcase total series within Kavita

==============================
* Added filtering to recently added

* Cleaned up the documentation on the APIs and removed params no longer needed.

* Implemented Filtering on library detail, in progress, and recently added for format. UI is non-final.

* Moved filtering to an expander panel

* Cleaned up filtering UI a bit

* Cleaned up some code and added titles on touched pages

* Fixed recently added not re-rendering page

* Removed commented out code

* Version bump

* Added an animation to the filtering section

* Stashing changes, needing to switch lazy loading libraries out due to current version not trigging on dom mutation events

* Finally fixed all the lazy loading issues and made it so pagination works without reloading the whole page.

* Supress max page error on bookmark api and just adjust the pages for the user. (#445)

# Changed
- Changed: Suppress max page error on bookmark api and just adjust the pages for the user.

* Filtering Bugs (#447)

# Fixed
- Fixed: Fixed an issue with filtering, after applying a filter, the cards on screen did not update with the correct information
- Fixed: Pagination is now slighlty smaller (only 8 pages) as on mobile, it was cutting off screen.

# Changed
- Changed: During library scan and series updates, Series names for Epubs will now trim excess white space
===============================================

* Fixed issue where some formats could get returned with another format filter.

* Filtering was not properly flushing DOM on filter change, updated trackbyidentity to account for filter

* One more fix for the filtering bug

* Made pagination UI slightly smaller to better fit on mobile phones. Trim() series names for Epub files and Trim() on series update for appropriate fields.

* Removed a no longer needed animation.

* Refactored logs to use a logs/ folder and also roll over after 10MB. … (#448)

# Changed
- Changed: Log files now roll (kavita, kavita1, etc) up to 5 files, each with a max of 10MB each. After all 5 files fill up, they will roll over. (Closes #446 )
=============================

* Refactored logs to use a logs/ folder and also roll over after 10MB. A maximum of 5 logs will persist (50MB of log data).

* Updated entrypoint to accommodate rolling logs

Co-authored-by: Chris Plaatjes <kizaing@gmail.com>

* Bugfix/in progress (#452)

# Fixed
- Fixed: Fixed an issue in In Progress where it would not pull the series with correct pagination due to the unique situation with the query. All series that are in progress should now return. 
============================
* Fixed In Progress not showing for all series due to pagination bug

* Version bump

* Adding version bump workflow

* Updating version-bump.yml

* Fixed the In progress API returning back series that didn't belong (#453)

* Fixed the In progress API returning back series that had another users progress on them. Added SplitQuery which speeds up query significantly.

* Try out putting PR body into notification

* Feature/version bump test (#449)

## Changes
- Reconfigured Github workflow for better visibility
- Added automated version bump on merge push to Develop branch

* Updating workflow

* Workflow fixes

* Updating workflow, removing branch creation

* Feature/version bump (#456)

** Changes
- Workflow changes

* Feature/version bump (#457)

## Changes
- Unprotected dev branch
- Added automated version bump on pushes to develop (merge PRs)

* Bump versions by dotnet-bump-version.

* Consolidating Stable Docker Tags

## Changes
- Combined stable and version tags into 1 task for workflow efficency

* Bump versions by dotnet-bump-version.

* Feature/docker cleanup (#459)

## Changes
- Cleaned up and fixed old docker files

* Bump versions by dotnet-bump-version.

* Update Notifier (#464)

# Added
- Added: Ability to check for updates (stable-only) and be notified with a changelog. This is a first pass implementation. 
- Added: Ability to use SignalR within Kavita (websockets)
=====================================

* (some debug code present). Implemented the ability to check and log if the server is up to date or not.

* Fixed a bug for dark mode where anchor buttons wouldn't have the correct font color.

Suppress filter/sort button if there is no filters to show.

Debug: Active indicators for users currently on your server.

Refactored code to send update notification only to admins. Admins now get a popup where they can open the Github release (docker users can just close).

* Fixed an issue where getLibraryNames on first load would call for as many cards there was on the screen. Now we call it much earlier and the data is cached faster.

* Fixed a dark mode bug from previous commit

* Release notes is now rendered markdown

* Implemented the ability to check for an update ad-hoc. Response will come via websocket to all admins.

* Fixed a missing padding

* Cleanup, added some temp code to carousel

* Cleaned up old stat stuff from dev config and added debug only flow for checking for update

* Misc cleanup

* Added readonly to one variable

* Fixed In Progress not showing for all series due to pagination bug

* Fixed the In progress API returning back series that had another users progress on them. Added SplitQuery which speeds up query significantly.

* SplitQuery in GetRecentlyAdded for a speed increase on API.

Fixed the logic on VersionUpdaterService to properly send on non-dev systems.

Disable the check button once it's triggered once since the API does a task, so it can't return anything.

* Cleaned up the admin actions to be more friendly on mobile.

* Cleaned up the message as we wait for SingalR to notify the user

* more textual changes

* Code smells

* Bump versions by dotnet-bump-version.

* Bookmarking Pages within the Reader (#469)

# Added
- Added: Added the ability to bookmark certain pages within the manga (image) reader and later download them from the series context menu. 

# Fixed
- Fixed: Fixed an issue where after adding a new folder to an existing library, a scan wouldn't be kicked off
- Fixed: In some cases, after clicking the background of a modal, the modal would close, but state wouldn't be handled as if cancel was pushed

# Changed
- Changed: Admin contextual actions on cards will now be separated by a line to help differentiate. 
- Changed: Performance enhancement on an API used before reading

# Dev
- Bumped dependencies to latest versions

=============================================
* Bumped versions of dependencies and refactored bookmark to progress.

* Refactored method names in UI from bookmark to progress to prepare for new bookmark entity

* Basic code is done, user can now bookmark a page (currently image reader only).

* Comments and pipes

* Some accessibility for new bookmark button

* Fixed up the APIs to work correctly, added a new modal to quickly explore bookmarks (not implemented, not final).

* Cleanup on the UI side to get the modal to look decent

* Added dismissed handlers for modals where appropriate

* Refactored UI to only show number of bookmarks across files to simplify delivery. Admin actionables are now separated by hr vs non-admin actions.

* Basic API implemented, now to implement the ability to actually extract files.

* Implemented the ability to download bookmarks.

* Fixed a bug where adding a new folder to an existing library would not trigger a scan library task.

* Fixed an issue that could cause bookmarked pages to get copied out of order.

* Added handler from series-card component

* Bump versions by dotnet-bump-version.

* Scan Issues (#470)

* Fixed an oversight where unique file extensions for KavitaStats wouldn't ignore case.

* Fixed an issue where series were getting removed then re-added due to bad logic when comparing if the series from disk matched the series in DB based on format.

* Bump versions by dotnet-bump-version.

* Updating docker workflow (#467)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* Bump versions by dotnet-bump-version.

* Updating Docker Workflow (#471)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* Bump versions by dotnet-bump-version.

* Updating discord workflow (#472)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Bump versions by dotnet-bump-version.

* Updating discord workflow (#474)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Bump versions by dotnet-bump-version.

* Updating discord workflow (#475)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Bump versions by dotnet-bump-version.

* Updating discord workflow (#477)

## Changes
- Changed github action to allow for discord notification to use markdown styling

## Added
- Added version to discord notification
- Added ability to use PR body instead of commit body for discord notification description.

* Bump versions by dotnet-bump-version.

* Updating discord action (#478)

## Changes
- Changed github action to allow for discord notification to use markdown styling

## Added
- Added version to discord notification
- Added ability to use PR body instead of commit body for discord notification description.

* Bump versions by dotnet-bump-version.

* Updating discord action (#479)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* Bump versions by dotnet-bump-version.

* Update discord action (#480)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Bump versions by dotnet-bump-version.

* Update discord action (#481)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Bump versions by dotnet-bump-version.

* Update discord action (#482)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* Bump versions by dotnet-bump-version.

* Download Refactor (#483)

# Added
- New: Cards when processing a download shows a spinner for the progress of the download

# Changed
- Changed: Downloads now always take the backend filename and are streamed in a more optimal manner, reducing the javascript processing that was needed previously.
==================================

* Started refactor of downloader to be more UX friendly and much faster.

* Completed refactor of Volume download to use a new mechanism. Downloads are streamed over and filename used exclusively from header. Backend has additional DB calls to get the Series Name information to make filenames nice.

* download service has been updated so all download functions use new event based observable. Duplicates code for downloading, but much cleaner and faster.

* Small code cleanup

* Bump versions by dotnet-bump-version.

* Update discord action (#486)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* Bump versions by dotnet-bump-version.

* Update discord action (#487)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* Bump versions by dotnet-bump-version.

* Feature/new discord action (#489)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* testing passing pr body via artifact

* Updating syntax

* Bump versions by dotnet-bump-version.

* Update Discord Action (#490)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* testing passing pr body via artifact

* Updating action version

* syntax fix

* Bump versions by dotnet-bump-version.

* Updating discord action (#492)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* testing passing pr body via artifact

* Updating action version

* syntax fix

* Updating write

* Bump versions by dotnet-bump-version.

* Update discord action (#493)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* testing passing pr body via artifact

* Updating action version

* syntax fix

* Updating write

* Updating write

* Bump versions by dotnet-bump-version.

* Updating discord action (#494)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* testing passing pr body via artifact

* Updating action version

* syntax fix

* Updating write

* Updating write

* testing artifact

* consolidating nightly-docker into main workflow

* Bump versions by dotnet-bump-version.

* Updating docker workflows (#495)

* Updating docker action

* adding version to message

* adding way to get PR title and body

* Adding input pass to workflows

Workflow dispatches may not get push header info. Switching logic to pass needed title and body info through just in case.

* adding token

The description on this action is awful, it didn't mention needing a token. I found this solution from the repo's issues

* parsing pr body

* Updating parsing

* Updating docker workflow

* Reverting back to default variables

* fixing syntax error

* removing heading syntax

* updating parse

* Updating parsing

* Updating docker workflow

* trying to remove newline

* updating parsing

* Replacement in parsing

* Update sonar-scan.yml

* testing passing pr body via artifact

* Updating action version

* syntax fix

* Updating write

* Updating write

* testing artifact

* consolidating nightly-docker into main workflow

* pulling stable into main

* Bump versions by dotnet-bump-version.

* Custom Cover Images (#499)

* Added some documentation. Removed Require Admin Role from Search Tags. Added Summary to be updated on UpdateTag.

* Added Swagger xml doc generation to beef up the documentation. Started adding xml comments to the APIs. This is a needed, slow task for upcoming Plugins system.

* Implemented the ability to upload a custom series image to override the existing cover image.

Refactored some code out to use ImageService and added more documentation

* When a page cache fails, delete cache directory so user can try to reload.

* Implemented the ability to lock a series cover image such that after user uploads something, it wont get refreshed by Kavita.

* Implemented the ability to reset cover image for series by unlocking

* Kick off a series refresh after a cover is unlocked.

* Ability to press enter to load a url

* Ability to reset selection

* Cleaned up cover chooser such that reset is nicer, errors inform user to use file upload, series edit modal now doesn't use scrollable body. Mobile tweaks. CoverImageLocked is now sent to the UI.

* More css changes to look better

* When no bookmarks, don't show both markups

* Fixed issues where images wouldn't refresh after cover image was changed.

* Implemented the ability to change the cover images for collection tags.

* Added property and API for chapter cover image update

* Added UI code to prepare for updating cover image for chapters. need to rearrange components

* Moved a ton of code around to separate card related screens into their own module.

* Implemented the ability to update a chapter/volume cover image

* Refactored action for volume to say edit to reflect modal action

* Fixed issue where after editing chapter cover image, the underlying card wouldn't update

* Fixed an issue where we were passing volumeId to the reset chapter lock. Changed some logic in volume cover image generation.

* Automatically apply when you hit reset cover image

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Collection Redesign (#500)

* Setup UI for the collection redesign.

* Implemented collection details page

* Bump versions by dotnet-bump-version.

* Close any open modals when a route change occurs (#501)

* Bump versions by dotnet-bump-version.

* Added manga/book routes back in (#503)

* Bump versions by dotnet-bump-version.

* Misc Bugfixes and Enhancements (#507)

* Removed some extra spam for the console

* Implemented the code to update RowVersion, which is our concurrency check

* Fixed a critical issue where more than one bookmark could occur for a given chapter due to a race condition. Now we use concurrency checks and we also gracefully allow more than one row, by only grabbing first.

* Cleaned up the logic for IHasConcurencyToken and updated the setters to be private.

* Lots of comments and when deleting a library, remove any user progress items for which chapters don't exist.

* When deleting a Series, cleanup user progress rows.

* Now after a scan of library, if a series is removed, collection tags are pruned as well if there are no longer any series bound to it.

* Updated the image on the Readme to show a better picture

* Small code cleanup to remove null check modifier as I check for null just before then

* Fixed images loading multiple times due to using function in binding with random. You can now click chapter images to read that chapter specifically.

* Fixed cards being different sizes when read vs unread

* Moved over Robbie's workflow changes from notifier. Commented out activity indicators as that is not shipping with this release.

* Remove code that isn't needed

* Reverted GA

* Changed GA to trigger only when HEAD is updated

* Bump versions by dotnet-bump-version.

* Feature/bookmark feedback (#508)

* ImageService had a stream reset before writting out to array. Added logging statment for updating series metadata. Removed ConcurencyCheck due to bad update issue for CollectionTag.

* Added a new screen which lets you quickly see all your bookmarks for a given user.

* Built user bookmark page in user settings. Moved user settings to it's own lazy loaded module. Removed unneded debouncing from downloader and just used throttleTime instead.

* Removed a not-yet implemented tab from series modal

* Fixed a bug in clear bookmarks and adjusted icons within anchors to have proper styling

* Bump versions by dotnet-bump-version.

* Misc Bugs (#510)

* Don't show bookmark context actionable for books

* Fixed an issue where when adding a collection tag, the logic wouldn't assume the tag already existed in DB and would reset it.

* After editing a series on library page, ensure we refresh collection tags.

* Reload recently added section after changing a series

* Moved all Stat logger events to Debug

* Refactored scroll logic into a single service to keep the code consistent.

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Update Notification Refactor (#511)

* Replaced profile links to anchors so we can open in new tab if we like

* Refactored how update checking works. We now explicitly check and send back on the same API. We have a weekly job that will push an update to the user.

* Implemented a changelog tab

* Ported over a GA fix for using ' in PR bodies.

* Don't check cert for Github

* Bump versions by dotnet-bump-version.

* Bugfix/release cleanup (#512)

* Lots of cleanup on the warnings in the solution. Deprecated IsLastWriteLessThan and made a new method HasFileBeenModifiedSince.

* Added some tests for the new extension method.

* Changed filter import to use correct import

* Scan Series now uses Refresh Metadata for Series, rather than library one.

* Fixed an issue where cover generation wasn't properly taking forced update into consideration. Removed a case of cover generation for no reason.

* Fixed series downloads not triggering backend call

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Parsing out cover images (#513)

- Fixing workflow
- Adding test case

* Bump versions by dotnet-bump-version.

* Prevent cover images showing as a series (#514)

* Parsing out cover images

- Fixing workflow
- Adding test case

* Fixing workflow

* Bump versions by dotnet-bump-version.

* v0.4.4 polishing (#515)

* Fixed a bad comparision between JsonElement and null.

* Removed console.logs. Fixed bug in edit collection detail page where summary wouldn't update after editing it.

* Removed a UI package that is no longer used.

Fixed an issue where MarkVolumeAsUnread was using an old set of APIs and now uses the new, faster API.

* Bump versions by dotnet-bump-version.

* Cover generation issue on first scan flow (#517)

* Cover generation issue on first scan flow

- Fixed logic around whether a chapter cover image should be generated. New logic adds grouping priority, changes an AND to an OR and adds an additional check to see if the cover image has been lock (custom image uploaded)

* Sonar update

* Refactored out the cover image updating logic to a new call (ShouldUpdateCoverImage) and updated ONLY chapters. Added a blank slate unit test to build out conditions.

* Fixed up unit case

* Fixed some logic on when to update a cover image

* Fixed an issue where 1) we were refreshing metadata anytime we adjusted cover image on a series and 2) Cover generation wasn't properly being handled on first run.

* Cleaned up the code for when a cover image change needs to trigger a refresh metadata task

Co-authored-by: Joseph Milazzo <joseph.v.milazzo@gmail.com>

* Update issue templates

* Bump versions by dotnet-bump-version.

* Bump versions by dotnet-bump-version.

* Version release (#519)

* Bump versions by dotnet-bump-version.

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Chris Plaatjes <kizaing@gmail.com>
This commit is contained in:
Joseph Milazzo 2021-08-23 07:13:16 -07:00 committed by GitHub
parent dbcec66b10
commit e646f9ce38
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
254 changed files with 10050 additions and 21966 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,38 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Desktop (please complete the following information):**
- OS: [e.g. iOS]
- Browser [e.g. chrome, safari]
- Version [e.g. 22] (can be found on Server Settings -> System tab)
**Smartphone (please complete the following information):**
- Device: [e.g. iPhone6]
- OS: [e.g. iOS8.1]
- Browser [e.g. stock browser, safari]
- Version [e.g. 22]
**Additional context**
Add any other context about the problem here.

View File

@ -1,88 +0,0 @@
name: Build Nightly Docker
on:
push:
branches:
- 'develop'
jobs:
docker:
name: Building Nightly Docker
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
with:
node-version: '14'
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
npm install
echo 'Building UI'
npm run prod
echo 'Copying back to Kavita wwwroot'
rsync -a dist/ ../../API/wwwroot/
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
- name: Echo csproj version
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Compile dotnet app
uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.x'
- run: ./monorepo-build.sh
- name: Trigger Sentry workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Sentry Map Release
token: ${{ secrets.REPO_GHA_PAT }}
inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: kizaing/kavita:nightly
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Notify Discord
uses: rjstone/discord-webhook-notify@v1
with:
severity: info
description:
details: 'https://hub.docker.com/r/kizaing/kavita/tags?page=1&ordering=last_updated'
text: A new nightly build has been released for docker.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}

View File

@ -2,14 +2,42 @@ name: .NET Build Test and Sonar Scan
on:
push:
branches: [ main, develop ]
branches: '**'
pull_request:
branches: [ main, develop ]
types: [opened, synchronize, reopened]
types: [synchronize]
jobs:
build:
name: Build and Scan
name: Build .Net
runs-on: windows-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 5.0.100
- name: Install dependencies
run: dotnet restore
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 1.11
- uses: actions/upload-artifact@v2
with:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
test:
name: Install Sonar & Test
needs: build
runs-on: windows-latest
steps:
- name: Checkout Repo
@ -52,7 +80,7 @@ jobs:
New-Item -Path .\.sonar\scanner -ItemType Directory
dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner
- name: Build and analyze
- name: Sonar Scan
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
@ -64,3 +92,242 @@ jobs:
- name: Test
run: dotnet test --no-restore --verbosity normal
version:
name: Bump version on Develop push
needs: [ build, test ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v1
with:
dotnet-version: 5.0.100
- name: Install dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Bump versions
uses: SiqiLu/dotnet-bump-version@master
with:
version_files: Kavita.Common/Kavita.Common.csproj
github_token: ${{ secrets.REPO_GHA_PAT }}
develop:
name: Build Nightly Docker if Develop push
needs: [ build, test, version ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
steps:
- name: Find Current Pull Request
uses: jwalton/gh-find-current-pr@v1.0.2
id: findPr
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Parse PR body
id: parse-body
run: |
body="${{ steps.findPr.outputs.body }}"
body=${body//\'/}
body=${body//'%'/'%25'}
body=${body//$'\n'/'%0A'}
body=${body//$'\r'/'%0D'}
echo $body
echo "::set-output name=BODY::$body"
- name: Check Out Repo
uses: actions/checkout@v2
with:
ref: develop
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
with:
node-version: '14'
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
npm install
echo 'Building UI'
npm run prod
echo 'Copying back to Kavita wwwroot'
rsync -a dist/ ../../API/wwwroot/
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
- name: Echo csproj version
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Compile dotnet app
uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.x'
- run: ./monorepo-build.sh
- name: Trigger Sentry workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Sentry Map Release
token: ${{ secrets.REPO_GHA_PAT }}
inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: kizaing/kavita:nightly
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Notify Discord
uses: rjstone/discord-webhook-notify@v1
with:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.parse-body.outputs.BODY }}'
text: A new nightly build has been released for docker.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}
stable:
name: Build Stable Docker if Main push
needs: [ build, test ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
steps:
- name: Find Current Pull Request
uses: jwalton/gh-find-current-pr@v1.0.2
id: findPr
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Parse PR body
id: parse-body
run: |
body="${{ steps.findPr.outputs.body }}"
body=${body//\'/}
body=${body//'%'/'%25'}
body=${body//$'\n'/'%0A'}
body=${body//$'\r'/'%0D'}
echo $body
echo "::set-output name=BODY::$body"
- name: Check Out Repo
uses: actions/checkout@v2
with:
ref: main
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
with:
node-version: '14'
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
npm install
echo 'Building UI'
npm run prod
echo 'Copying back to Kavita wwwroot'
rsync -a dist/ ../../API/wwwroot/
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
- name: Echo csproj version
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Parse Version
run: |
version='${{steps.get-version.outputs.assembly-version}}'
newVersion=${version%.*}
echo $newVersion
echo "::set-output name=VERSION::$newVersion"
id: parse-version
- name: Compile dotnet app
uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.x'
- run: ./monorepo-build.sh
- name: Trigger Sentry workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Sentry Map Release
token: ${{ secrets.REPO_GHA_PAT }}
inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: kizaing/kavita:latest, kizaing/kavita:${{ steps.parse-version.outputs.VERSION }}
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Notify Discord
uses: rjstone/discord-webhook-notify@v1
with:
severity: info
description: v${{steps.get-version.outputs.assembly-version}} - ${{ steps.findPr.outputs.title }}
details: '${{ steps.parse-body.outputs.BODY }}'
text: A new stable build has been released.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}

View File

@ -1,88 +0,0 @@
name: Build Stable Docker
on:
push:
branches:
- 'main'
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: NodeJS to Compile WebUI
uses: actions/setup-node@v2.1.5
with:
node-version: '14'
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
npm install
echo 'Building UI'
npm run prod
echo 'Copying back to Kavita wwwroot'
rsync -a dist/ ../../API/wwwroot/
cd ../ || exit
- name: Get csproj Version
uses: naminodarie/get-net-sdk-project-versions-action@v1
id: get-version
with:
proj-path: Kavita.Common/Kavita.Common.csproj
- name: Echo csproj version
run: echo "${{steps.get-version.outputs.assembly-version}}"
- name: Compile dotnet app
uses: actions/setup-dotnet@v1
with:
dotnet-version: '5.0.x'
- run: ./monorepo-build.sh
- name: Trigger Sentry workflow
uses: benc-uk/workflow-dispatch@v1
with:
workflow: Sentry Map Release
token: ${{ secrets.REPO_GHA_PAT }}
inputs: '{ "version": "${{steps.get-version.outputs.assembly-version}}" }'
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: true
tags: kizaing/kavita:latest
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Notify Discord
uses: rjstone/discord-webhook-notify@v1
with:
severity: info
description:
details: 'https://hub.docker.com/r/kizaing/kavita/tags?page=1&ordering=last_updated'
text: A new stable build has been released for docker.
webhookUrl: ${{ secrets.DISCORD_DOCKER_UPDATE_URL }}

View File

@ -7,15 +7,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="5.0.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="5.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.10.0" />
<PackageReference Include="NSubstitute" Version="4.2.2" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="3.0.3">
<PackageReference Include="coverlet.collector" Version="3.1.0">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>

View File

@ -1,21 +1,33 @@
namespace API.Tests.Extensions
using System;
using System.Globalization;
using System.IO;
using API.Extensions;
using Xunit;
namespace API.Tests.Extensions
{
public class FileInfoExtensionsTests
{
// [Fact]
// public void DoesLastWriteMatchTest()
// {
// var fi = Substitute.For<FileInfo>();
// fi.LastWriteTime = DateTime.Now;
//
// var deltaTime = DateTime.Today.Subtract(TimeSpan.FromDays(1));
// Assert.False(fi.DoesLastWriteMatch(deltaTime));
// }
//
// [Fact]
// public void IsLastWriteLessThanTest()
// {
//
// }
private static readonly string TestDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Extensions/Test Data/");
[Fact]
public void HasFileBeenModifiedSince_ShouldBeFalse()
{
var filepath = Path.Join(TestDirectory, "not modified.txt");
var date = new FileInfo(filepath).LastWriteTime;
Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
File.ReadAllText(filepath);
Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
}
[Fact]
public void HasFileBeenModifiedSince_ShouldBeTrue()
{
var filepath = Path.Join(TestDirectory, "modified on run.txt");
var date = new FileInfo(filepath).LastWriteTime;
Assert.False(new FileInfo(filepath).HasFileBeenModifiedSince(date));
File.AppendAllLines(filepath, new[] { DateTime.Now.ToString(CultureInfo.InvariantCulture) });
Assert.True(new FileInfo(filepath).HasFileBeenModifiedSince(date));
}
}
}

View File

@ -0,0 +1,3 @@
This file should be modified by the unit test08/20/2021 10:26:03
08/20/2021 10:26:29
08/22/2021 12:39:58

View File

@ -0,0 +1 @@
Hello, this file should not be modified

View File

@ -22,6 +22,7 @@ namespace API.Tests.Parser
[InlineData("Invincible Vol 01 Family matters (2005) (Digital).cbr", "Invincible")]
[InlineData("Amazing Man Comics chapter 25", "Amazing Man Comics")]
[InlineData("Amazing Man Comics issue #25", "Amazing Man Comics")]
[InlineData("Teen Titans v1 038 (1972) (c2c).cbr", "Teen Titans")]
public void ParseComicSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseComicSeries(filename));

View File

@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using API.Entities.Enums;
using API.Parser;
using Xunit;
@ -64,6 +64,7 @@ namespace API.Tests.Parser
[InlineData("Sword Art Online Vol 10 - Alicization Running [Yen Press] [LuCaZ] {r2}.epub", "10")]
[InlineData("Noblesse - Episode 406 (52 Pages).7z", "0")]
[InlineData("X-Men v1 #201 (September 2007).cbz", "1")]
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "6")]
public void ParseVolumeTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseVolume(filename));
@ -154,6 +155,7 @@ namespace API.Tests.Parser
[InlineData("Please Go Home, Akutsu-San! - Chapter 038.5 - Volume Announcement.cbz", "Please Go Home, Akutsu-San!")]
[InlineData("Killing Bites - Vol 11 Chapter 050 Save Me, Nunupi!.cbz", "Killing Bites")]
[InlineData("Mad Chimera World - Volume 005 - Chapter 026.cbz", "Mad Chimera World")]
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "Hentai Ouji to Warawanai Neko.")]
public void ParseSeriesTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseSeries(filename));
@ -222,6 +224,7 @@ namespace API.Tests.Parser
[InlineData("Boku No Kokoro No Yabai Yatsu - Chapter 054 I Prayed At The Shrine (V0).cbz", "54")]
[InlineData("Ijousha No Ai - Vol.01 Chapter 029 8 Years Ago", "29")]
[InlineData("Kedouin Makoto - Corpse Party Musume, Chapter 09.cbz", "9")]
[InlineData("Hentai Ouji to Warawanai Neko. - Vol. 06 Ch. 034.5", "34.5")]
public void ParseChaptersTest(string filename, string expected)
{
Assert.Equal(expected, API.Parser.Parser.ParseChapter(filename));
@ -275,6 +278,7 @@ namespace API.Tests.Parser
Assert.Equal(expected, API.Parser.Parser.ParseMangaSpecial(inputFile));
}
/*
private static ParserInfo CreateParserInfo(string series, string chapter, string volume, bool isSpecial = false)
{
return new ParserInfo()
@ -285,6 +289,7 @@ namespace API.Tests.Parser
Series = series,
};
}
*/
[Theory]
[InlineData("/manga/Btooom!/Vol.1/Chapter 1/1.cbz", "Btooom!~1~1")]
@ -402,6 +407,10 @@ namespace API.Tests.Parser
FullFilePath = filepath, IsSpecial = false
});
// If an image is cover exclusively, ignore it
filepath = @"E:\Manga\Seraph of the End\cover.png";
expected.Add(filepath, null);
foreach (var file in expected.Keys)
{

View File

@ -0,0 +1,105 @@
using System;
using System.IO;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services
{
public class MetadataServiceTests
{
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ArchiveService/Archives");
private readonly MetadataService _metadataService;
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly IImageService _imageService = Substitute.For<IImageService>();
private readonly IBookService _bookService = Substitute.For<IBookService>();
private readonly IArchiveService _archiveService = Substitute.For<IArchiveService>();
private readonly ILogger<MetadataService> _logger = Substitute.For<ILogger<MetadataService>>();
public MetadataServiceTests()
{
_metadataService = new MetadataService(_unitOfWork, _logger, _archiveService, _bookService, _imageService);
}
[Fact]
public void ShouldUpdateCoverImage_OnFirstRun()
{
// Represents first run
Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = DateTime.Now
}, false, false));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_FileModified()
{
// Represents first run
Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime.Subtract(TimeSpan.FromDays(1))
}, false, false));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_CoverImageLocked()
{
// Represents first run
Assert.False(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
}, false, true));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_ForceUpdate()
{
// Represents first run
Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
}, true, false));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_NoFileChangeButNoCoverImage()
{
// Represents first run
Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
}, false, false));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_FileChangeButNoCoverImage()
{
// Represents first run
Assert.True(MetadataService.ShouldUpdateCoverImage(null, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime + TimeSpan.FromDays(1)
}, false, false));
}
[Fact]
public void ShouldUpdateCoverImage_OnSecondRun_CoverImageSet()
{
// Represents first run
Assert.False(MetadataService.ShouldUpdateCoverImage(new byte[] {1}, new MangaFile()
{
FilePath = Path.Join(_testDirectory, "file in folder.zip"),
LastModified = new FileInfo(Path.Join(_testDirectory, "file in folder.zip")).LastWriteTime
}, false, false));
}
}
}

View File

@ -33,7 +33,6 @@ namespace API.Tests.Services
private readonly IBookService _bookService = Substitute.For<IBookService>();
private readonly IImageService _imageService = Substitute.For<IImageService>();
private readonly ILogger<MetadataService> _metadataLogger = Substitute.For<ILogger<MetadataService>>();
private readonly IDirectoryService _directoryService = Substitute.For<IDirectoryService>();
private readonly ICacheService _cacheService = Substitute.For<ICacheService>();
private readonly DbConnection _connection;

View File

@ -12,6 +12,10 @@
<ApplicationIcon>../favicon.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\Debug\API.xml</DocumentationFile>
</PropertyGroup>
<!-- Set the Product and Version info for our own projects -->
<PropertyGroup>
<Product>Kavita</Product>
@ -33,33 +37,37 @@
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="8.1.1" />
<PackageReference Include="Docnet.Core" Version="2.3.1" />
<PackageReference Include="ExCSS" Version="4.1.0" />
<PackageReference Include="Hangfire" Version="1.7.20" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.20" />
<PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="Flurl.Http" Version="3.2.0" />
<PackageReference Include="Hangfire" Version="1.7.24" />
<PackageReference Include="Hangfire.AspNetCore" Version="1.7.24" />
<PackageReference Include="Hangfire.MaximumConcurrentExecutions" Version="1.1.0" />
<PackageReference Include="Hangfire.MemoryStorage.Core" Version="1.4.0" />
<PackageReference Include="HtmlAgilityPack" Version="1.11.32" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.4">
<PackageReference Include="HtmlAgilityPack" Version="1.11.35" />
<PackageReference Include="MarkdownDeep.NET.Core" Version="1.5.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="5.0.8" />
<PackageReference Include="Microsoft.AspNetCore.SignalR" Version="1.1.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.4" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.8" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.2" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="2.1.3" />
<PackageReference Include="NetVips" Version="2.0.1" />
<PackageReference Include="NetVips.Native" Version="8.11.0" />
<PackageReference Include="NReco.Logging.File" Version="1.1.1" />
<PackageReference Include="Sentry.AspNetCore" Version="3.8.2" />
<PackageReference Include="NReco.Logging.File" Version="1.1.2" />
<PackageReference Include="Sentry.AspNetCore" Version="3.8.3" />
<PackageReference Include="SharpCompress" Version="0.28.3" />
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.26.0.34506">
<PackageReference Include="SonarAnalyzer.CSharp" Version="8.27.0.35380">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.1.5" />
<PackageReference Include="System.Drawing.Common" Version="5.0.2" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.10.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.12.0" />
<PackageReference Include="VersOne.Epub" Version="3.0.3.1" />
</ItemGroup>
@ -73,22 +81,40 @@
<None Remove="Hangfire-log.db" />
<None Remove="obj\**" />
<None Remove="wwwroot\**" />
<None Remove="cache\**" />
<None Remove="backups\**" />
<None Remove="logs\**" />
<None Remove="temp\**" />
<None Remove="kavita.log" />
<None Remove="kavita.db" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Interfaces\IMetadataService.cs" />
<Compile Remove="obj\**" />
<Compile Remove="wwwroot\**" />
<Compile Remove="cache\**" />
<Compile Remove="backups\**" />
<Compile Remove="logs\**" />
<Compile Remove="temp\**" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Remove="obj\**" />
<EmbeddedResource Remove="wwwroot\**" />
<EmbeddedResource Remove="cache\**" />
<EmbeddedResource Remove="backups\**" />
<EmbeddedResource Remove="logs\**" />
<EmbeddedResource Remove="temp\**" />
</ItemGroup>
<ItemGroup>
<Content Remove="obj\**" />
<Content Remove="wwwroot\**" />
<Content Remove="cache\**" />
<Content Remove="backups\**" />
<Content Remove="logs\**" />
<Content Remove="temp\**" />
</ItemGroup>
<ItemGroup>
@ -215,4 +241,8 @@
<_ContentIncludedByDefault Remove="wwwroot\vendor.6b2a0912ae80e6fd297f.js.map" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Drawing.Common" />
</ItemGroup>
</Project>

View File

@ -5,8 +5,17 @@
/// </summary>
public enum ArchiveLibrary
{
/// <summary>
/// The underlying archive cannot be opened
/// </summary>
NotSupported = 0,
/// <summary>
/// The underlying archive can be opened by SharpCompress
/// </summary>
SharpCompress = 1,
/// <summary>
/// The underlying archive can be opened by default .NET
/// </summary>
Default = 2
}
}

View File

@ -1,7 +0,0 @@
namespace API.Archive
{
public class CoverAndPages
{
}
}

View File

@ -1,8 +1,17 @@
namespace API.Constants
{
/// <summary>
/// Role-based Security
/// </summary>
public static class PolicyConstants
{
/// <summary>
/// Admin User. Has all privileges
/// </summary>
public const string AdminRole = "Admin";
/// <summary>
/// Non-Admin User. Must be granted privileges by an Admin.
/// </summary>
public const string PlebRole = "Pleb";
/// <summary>
/// Used to give a user ability to download files from the server

View File

@ -18,6 +18,9 @@ using Microsoft.Extensions.Logging;
namespace API.Controllers
{
/// <summary>
/// All Account matters
/// </summary>
public class AccountController : BaseApiController
{
private readonly UserManager<AppUser> _userManager;
@ -27,6 +30,7 @@ namespace API.Controllers
private readonly ILogger<AccountController> _logger;
private readonly IMapper _mapper;
/// <inheritdoc />
public AccountController(UserManager<AppUser> userManager,
SignInManager<AppUser> signInManager,
ITokenService tokenService, IUnitOfWork unitOfWork,
@ -41,6 +45,11 @@ namespace API.Controllers
_mapper = mapper;
}
/// <summary>
/// Update a user's password
/// </summary>
/// <param name="resetPasswordDto"></param>
/// <returns></returns>
[HttpPost("reset-password")]
public async Task<ActionResult> UpdatePassword(ResetPasswordDto resetPasswordDto)
{
@ -80,6 +89,11 @@ namespace API.Controllers
return Ok();
}
/// <summary>
/// Register a new user on the server
/// </summary>
/// <param name="registerDto"></param>
/// <returns></returns>
[HttpPost("register")]
public async Task<ActionResult<UserDto>> Register(RegisterDto registerDto)
{
@ -134,6 +148,11 @@ namespace API.Controllers
return BadRequest("Something went wrong when registering user");
}
/// <summary>
/// Perform a login. Will send JWT Token of the logged in user back.
/// </summary>
/// <param name="loginDto"></param>
/// <returns></returns>
[HttpPost("login")]
public async Task<ActionResult<UserDto>> Login(LoginDto loginDto)
{
@ -165,6 +184,10 @@ namespace API.Controllers
};
}
/// <summary>
/// Get All Roles back. See <see cref="PolicyConstants"/>
/// </summary>
/// <returns></returns>
[HttpGet("roles")]
public ActionResult<IList<string>> GetRoles()
{
@ -175,6 +198,11 @@ namespace API.Controllers
f => (string) f.GetValue(null)).Values.ToList();
}
/// <summary>
/// Sets the given roles to the user.
/// </summary>
/// <param name="updateRbsDto"></param>
/// <returns></returns>
[HttpPost("update-rbs")]
public async Task<ActionResult> UpdateRoles(UpdateRbsDto updateRbsDto)
{

View File

@ -18,14 +18,16 @@ namespace API.Controllers
private readonly ILogger<BookController> _logger;
private readonly IBookService _bookService;
private readonly IUnitOfWork _unitOfWork;
private readonly ICacheService _cacheService;
private static readonly string BookApiUrl = "book-resources?file=";
public BookController(ILogger<BookController> logger, IBookService bookService, IUnitOfWork unitOfWork)
public BookController(ILogger<BookController> logger, IBookService bookService, IUnitOfWork unitOfWork, ICacheService cacheService)
{
_logger = logger;
_bookService = bookService;
_unitOfWork = unitOfWork;
_cacheService = cacheService;
}
[HttpGet("{chapterId}/book-info")]
@ -169,9 +171,11 @@ namespace API.Controllers
[HttpGet("{chapterId}/book-page")]
public async Task<ActionResult<string>> GetBookPage(int chapterId, [FromQuery] int page)
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
var chapter = await _cacheService.Ensure(chapterId);
var path = _cacheService.GetCachedEpubFile(chapter.Id, chapter);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath);
using var book = await EpubReader.OpenBookAsync(path);
var mappings = await _bookService.CreateKeyToPageMappingAsync(book);
var counter = 0;
@ -196,12 +200,7 @@ namespace API.Controllers
{
if (doc.ParseErrors.Any())
{
_logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName);
foreach (var error in doc.ParseErrors)
{
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
}
LogBookErrors(book, contentFileRef, doc);
return BadRequest("The file is malformed! Cannot read.");
}
_logger.LogError("{FilePath} has no body tag! Generating one for support. Book may be skewed", book.FilePath);
@ -322,5 +321,14 @@ namespace API.Controllers
return BadRequest("Could not find the appropriate html for that page");
}
private void LogBookErrors(EpubBookRef book, EpubTextContentFileRef contentFileRef, HtmlDocument doc)
{
_logger.LogError("{FilePath} has an invalid html file (Page {PageName})", book.FilePath, contentFileRef.FileName);
foreach (var error in doc.ParseErrors)
{
_logger.LogError("Line {LineNumber}, Reason: {Reason}", error.Line, error.Reason);
}
}
}
}

View File

@ -13,17 +13,25 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
/// <summary>
/// APIs for Collections
/// </summary>
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
}
/// <summary>
/// Return a list of all collection tags on the server
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IEnumerable<CollectionTagDto>> GetAllTags()
{
@ -36,6 +44,12 @@ namespace API.Controllers
return await _unitOfWork.CollectionTagRepository.GetAllPromotedTagDtosAsync();
}
/// <summary>
/// Searches against the collection tags on the DB and returns matches that meet the search criteria.
/// <remarks>Search strings will be cleaned of certain fields, like %</remarks>
/// </summary>
/// <param name="queryString">Search term</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("search")]
public async Task<IEnumerable<CollectionTagDto>> SearchTags(string queryString)
@ -47,16 +61,23 @@ namespace API.Controllers
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString);
}
/// <summary>
/// Updates an existing tag with a new title, promotion status, and summary.
/// <remarks>UI does not contain controls to update title</remarks>
/// </summary>
/// <param name="updatedTag"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateTag(CollectionTagDto updatedTag)
public async Task<ActionResult> UpdateTagPromotion(CollectionTagDto updatedTag)
{
var existingTag = await _unitOfWork.CollectionTagRepository.GetTagAsync(updatedTag.Id);
if (existingTag == null) return BadRequest("This tag does not exist");
existingTag.Promoted = updatedTag.Promoted;
existingTag.Title = updatedTag.Title;
existingTag.Title = updatedTag.Title.Trim();
existingTag.NormalizedTitle = Parser.Parser.Normalize(updatedTag.Title).ToUpper();
existingTag.Summary = updatedTag.Summary.Trim();
if (_unitOfWork.HasChanges())
{
@ -73,6 +94,11 @@ namespace API.Controllers
return BadRequest("Something went wrong, please try again");
}
/// <summary>
/// For a given tag, update the summary if summary has changed and remove a set of series from the tag.
/// </summary>
/// <param name="updateSeriesForTagDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task<ActionResult> UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto)
@ -90,6 +116,15 @@ namespace API.Controllers
_unitOfWork.CollectionTagRepository.Update(tag);
}
tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked;
if (!updateSeriesForTagDto.Tag.CoverImageLocked)
{
tag.CoverImageLocked = false;
tag.CoverImage = Array.Empty<byte>();
_unitOfWork.CollectionTagRepository.Update(tag);
}
foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
@ -101,7 +136,9 @@ namespace API.Controllers
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (_unitOfWork.HasChanges() && await _unitOfWork.CommitAsync())
if (!_unitOfWork.HasChanges()) return Ok("No updates");
if (await _unitOfWork.CommitAsync())
{
return Ok("Tag updated");
}

View File

@ -3,7 +3,10 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.DTOs.Downloads;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
@ -21,12 +24,17 @@ namespace API.Controllers
private readonly IUnitOfWork _unitOfWork;
private readonly IArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly NumericComparer _numericComparer;
private const string DefaultContentType = "application/octet-stream"; // "application/zip"
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService)
public DownloadController(IUnitOfWork unitOfWork, IArchiveService archiveService, IDirectoryService directoryService, ICacheService cacheService)
{
_unitOfWork = unitOfWork;
_archiveService = archiveService;
_directoryService = directoryService;
_cacheService = cacheService;
_numericComparer = new NumericComparer();
}
[HttpGet("volume-size")]
@ -39,7 +47,7 @@ namespace API.Controllers
[HttpGet("chapter-size")]
public async Task<ActionResult<long>> GetChapterSize(int chapterId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
return Ok(DirectoryService.GetTotalSize(files.Select(c => c.FilePath)));
}
@ -54,15 +62,17 @@ namespace API.Controllers
public async Task<ActionResult> DownloadVolume(int volumeId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForVolume(volumeId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(volumeId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try
{
if (files.Count == 1)
{
return await GetFirstFileDownload(files);
}
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_v{volumeId}");
return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip");
return File(fileBytes, DefaultContentType, $"{series.Name} - Volume {volume.Number}.zip");
}
catch (KavitaException ex)
{
@ -96,16 +106,19 @@ namespace API.Controllers
[HttpGet("chapter")]
public async Task<ActionResult> DownloadChapter(int chapterId)
{
var files = await _unitOfWork.VolumeRepository.GetFilesForChapter(chapterId);
var files = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeByIdAsync(chapter.VolumeId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
try
{
if (files.Count == 1)
{
return await GetFirstFileDownload(files);
}
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_c{chapterId}");
return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip");
return File(fileBytes, DefaultContentType, $"{series.Name} - Chapter {chapter.Number}.zip");
}
catch (KavitaException ex)
{
@ -117,20 +130,78 @@ namespace API.Controllers
public async Task<ActionResult> DownloadSeries(int seriesId)
{
var files = await _unitOfWork.SeriesRepository.GetFilesForSeries(seriesId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
try
{
if (files.Count == 1)
{
return await GetFirstFileDownload(files);
}
var (fileBytes, zipPath) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
var (fileBytes, _) = await _archiveService.CreateZipForDownload(files.Select(c => c.FilePath),
$"download_{User.GetUsername()}_s{seriesId}");
return File(fileBytes, "application/zip", Path.GetFileNameWithoutExtension(zipPath) + ".zip");
return File(fileBytes, DefaultContentType, $"{series.Name}.zip");
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
}
[HttpPost("bookmarks")]
public async Task<ActionResult> DownloadBookmarkPages(DownloadBookmarkDto downloadBookmarkDto)
{
// We know that all bookmarks will be for one single seriesId
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
var totalFilePaths = new List<string>();
var tempFolder = $"download_{series.Id}_bookmarks";
var fullExtractPath = Path.Join(DirectoryService.TempDirectory, tempFolder);
if (new DirectoryInfo(fullExtractPath).Exists)
{
return BadRequest(
"Server is currently processing this exact download. Please try again in a few minutes.");
}
DirectoryService.ExistOrCreate(fullExtractPath);
var uniqueChapterIds = downloadBookmarkDto.Bookmarks.Select(b => b.ChapterId).Distinct().ToList();
foreach (var chapterId in uniqueChapterIds)
{
var chapterExtractPath = Path.Join(fullExtractPath, $"{series.Id}_bookmark_{chapterId}");
var chapterPages = downloadBookmarkDto.Bookmarks.Where(b => b.ChapterId == chapterId)
.Select(b => b.Page).ToList();
var mangaFiles = await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId);
switch (series.Format)
{
case MangaFormat.Image:
DirectoryService.ExistOrCreate(chapterExtractPath);
_directoryService.CopyFilesToDirectory(mangaFiles.Select(f => f.FilePath), chapterExtractPath, $"{chapterId}_");
break;
case MangaFormat.Archive:
case MangaFormat.Pdf:
_cacheService.ExtractChapterFiles(chapterExtractPath, mangaFiles.ToList());
var originalFiles = _directoryService.GetFilesWithExtension(chapterExtractPath,
Parser.Parser.ImageFileExtensions);
_directoryService.CopyFilesToDirectory(originalFiles, chapterExtractPath, $"{chapterId}_");
DirectoryService.DeleteFiles(originalFiles);
break;
case MangaFormat.Epub:
return BadRequest("Series is not in a valid format.");
default:
return BadRequest("Series is not in a valid format. Please rescan series and try again.");
}
var files = _directoryService.GetFilesWithExtension(chapterExtractPath, Parser.Parser.ImageFileExtensions);
// Filter out images that aren't in bookmarks
Array.Sort(files, _numericComparer);
totalFilePaths.AddRange(files.Where((_, i) => chapterPages.Contains(i)));
}
var (fileBytes, _) = await _archiveService.CreateZipForDownload(totalFilePaths,
tempFolder);
DirectoryService.ClearAndDeleteDirectory(fullExtractPath);
return File(fileBytes, DefaultContentType, $"{series.Name} - Bookmarks.zip");
}
}
}

View File

@ -5,57 +5,78 @@ using Microsoft.AspNetCore.Mvc;
namespace API.Controllers
{
/// <summary>
/// Responsible for servicing up images stored in the DB
/// </summary>
public class ImageController : BaseApiController
{
private const string Format = "jpeg";
private readonly IUnitOfWork _unitOfWork;
/// <inheritdoc />
public ImageController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
/// <summary>
/// Returns cover image for Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-cover")]
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
{
var content = await _unitOfWork.VolumeRepository.GetChapterCoverImageAsync(chapterId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"chapterId");
return File(content, "image/" + Format, $"{chapterId}");
}
/// <summary>
/// Returns cover image for Volume
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
{
var content = await _unitOfWork.SeriesRepository.GetVolumeCoverImageAsync(volumeId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"volumeId");
return File(content, "image/" + Format, $"{volumeId}");
}
/// <summary>
/// Returns cover image for Series
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId)
{
var content = await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"seriesId");
return File(content, "image/" + Format, $"{seriesId}");
}
/// <summary>
/// Returns cover image for Collection Tag
/// </summary>
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
{
var content = await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId);
if (content == null) return BadRequest("No cover image");
const string format = "jpeg";
Response.AddCacheHeader(content);
return File(content, "image/" + format, $"collectionTagId");
return File(content, "image/" + Format, $"{collectionTagId}");
}
}
}

View File

@ -185,6 +185,8 @@ namespace API.Controllers
if (chapterIds.Any())
{
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
await _unitOfWork.CommitAsync();
_taskScheduler.CleanupChapters(chapterIds);
}
return Ok(true);
@ -203,8 +205,7 @@ namespace API.Controllers
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id);
var originalFolders = library.Folders.Select(x => x.Path);
var differenceBetweenFolders = originalFolders.Except(libraryForUserDto.Folders);
var originalFolders = library.Folders.Select(x => x.Path).ToList();
library.Name = libraryForUserDto.Name;
library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
@ -212,9 +213,9 @@ namespace API.Controllers
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library.");
if (differenceBetweenFolders.Any())
if (originalFolders.Count != libraryForUserDto.Folders.Count())
{
_taskScheduler.ScanLibrary(library.Id, true);
_taskScheduler.ScanLibrary(library.Id);
}
return Ok();

View File

@ -11,25 +11,38 @@ using API.Extensions;
using API.Interfaces;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
/// <summary>
/// For all things regarding reading, mainly focusing on non-Book related entities
/// </summary>
public class ReaderController : BaseApiController
{
private readonly IDirectoryService _directoryService;
private readonly ICacheService _cacheService;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReaderController> _logger;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
private readonly ChapterSortComparerZeroFirst _chapterSortComparerForInChapterSorting = new ChapterSortComparerZeroFirst();
private readonly NaturalSortComparer _naturalSortComparer = new NaturalSortComparer();
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork)
/// <inheritdoc />
public ReaderController(IDirectoryService directoryService, ICacheService cacheService, IUnitOfWork unitOfWork, ILogger<ReaderController> logger)
{
_directoryService = directoryService;
_cacheService = cacheService;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Returns an image for a given chapter. Side effect: This will cache the chapter images for reading.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="page"></param>
/// <returns></returns>
[HttpGet("image")]
public async Task<ActionResult> GetImage(int chapterId, int page)
{
@ -37,6 +50,8 @@ namespace API.Controllers
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
try
{
var (path, _) = await _cacheService.GetCachedPagePath(chapter, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}");
@ -48,17 +63,30 @@ namespace API.Controllers
return File(content, "image/" + format);
}
catch (Exception)
{
_cacheService.CleanupChapters(new []{ chapterId });
throw;
}
}
/// <summary>
/// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading.
/// </summary>
/// <param name="seriesId"></param>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-info")]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId)
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int seriesId, int chapterId)
{
// PERF: Write this in one DB call
var chapter = await _cacheService.Ensure(chapterId);
if (chapter == null) return BadRequest("Could not find Chapter");
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
var volume = await _unitOfWork.SeriesRepository.GetVolumeDtoAsync(chapter.VolumeId);
if (volume == null) return BadRequest("Could not find Volume");
var (_, mangaFile) = await _cacheService.GetCachedPagePath(chapter, 0);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId);
var mangaFile = (await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapterId)).First();
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
return Ok(new ChapterInfoDto()
{
@ -72,29 +100,6 @@ namespace API.Controllers
});
}
[HttpGet("get-bookmark")]
public async Task<ActionResult<BookmarkDto>> GetBookmark(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var bookmark = new BookmarkDto()
{
PageNum = 0,
ChapterId = chapterId,
VolumeId = 0,
SeriesId = 0
};
if (user.Progresses == null) return Ok(bookmark);
var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
if (progress != null)
{
bookmark.SeriesId = progress.SeriesId;
bookmark.VolumeId = progress.VolumeId;
bookmark.PageNum = progress.PagesRead;
bookmark.BookScrollId = progress.BookScrollId;
}
return Ok(bookmark);
}
[HttpPost("mark-read")]
public async Task<ActionResult> MarkRead(MarkReadDto markReadDto)
@ -106,8 +111,9 @@ namespace API.Controllers
{
foreach (var chapter in volume.Chapters)
{
var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
if (userProgress == null) // I need to get all chapters and generate new user progresses for them?
var userProgress = GetUserProgressForChapter(user, chapter);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
@ -137,6 +143,36 @@ namespace API.Controllers
return BadRequest("There was an issue saving progress");
}
private static AppUserProgress GetUserProgressForChapter(AppUser user, Chapter chapter)
{
AppUserProgress userProgress = null;
try
{
userProgress =
user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
}
catch (Exception)
{
// There is a very rare chance that user progress will duplicate current row. If that happens delete one with less pages
var progresses = user.Progresses.Where(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id).ToList();
if (progresses.Count > 1)
{
user.Progresses = new List<AppUserProgress>()
{
user.Progresses.First()
};
userProgress = user.Progresses.First();
}
}
return userProgress;
}
/// <summary>
/// Marks a Chapter as Unread (progress)
/// </summary>
/// <param name="markReadDto"></param>
/// <returns></returns>
[HttpPost("mark-unread")]
public async Task<ActionResult> MarkUnread(MarkReadDto markReadDto)
{
@ -147,25 +183,14 @@ namespace API.Controllers
{
foreach (var chapter in volume.Chapters)
{
var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
PagesRead = 0,
VolumeId = volume.Id,
SeriesId = markReadDto.SeriesId,
ChapterId = chapter.Id
});
}
else
{
var userProgress = GetUserProgressForChapter(user, chapter);
if (userProgress == null) continue;
userProgress.PagesRead = 0;
userProgress.SeriesId = markReadDto.SeriesId;
userProgress.VolumeId = volume.Id;
}
}
}
_unitOfWork.UserRepository.Update(user);
@ -178,6 +203,11 @@ namespace API.Controllers
return BadRequest("There was an issue saving progress");
}
/// <summary>
/// Marks all chapters within a volume as Read
/// </summary>
/// <param name="markVolumeReadDto"></param>
/// <returns></returns>
[HttpPost("mark-volume-read")]
public async Task<ActionResult> MarkVolumeAsRead(MarkVolumeReadDto markVolumeReadDto)
{
@ -187,7 +217,7 @@ namespace API.Controllers
foreach (var chapter in chapters)
{
user.Progresses ??= new List<AppUserProgress>();
var userProgress = user.Progresses.SingleOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
var userProgress = user.Progresses.FirstOrDefault(x => x.ChapterId == chapter.Id && x.AppUserId == user.Id);
if (userProgress == null)
{
@ -217,48 +247,81 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
[HttpPost("bookmark")]
public async Task<ActionResult> Bookmark(BookmarkDto bookmarkDto)
/// <summary>
/// Returns Progress (page number) for a chapter for the logged in user
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("get-progress")]
public async Task<ActionResult<ProgressDto>> GetProgress(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var progressBookmark = new ProgressDto()
{
PageNum = 0,
ChapterId = chapterId,
VolumeId = 0,
SeriesId = 0
};
if (user.Progresses == null) return Ok(progressBookmark);
var progress = user.Progresses.SingleOrDefault(x => x.AppUserId == user.Id && x.ChapterId == chapterId);
if (progress != null)
{
progressBookmark.SeriesId = progress.SeriesId;
progressBookmark.VolumeId = progress.VolumeId;
progressBookmark.PageNum = progress.PagesRead;
progressBookmark.BookScrollId = progress.BookScrollId;
}
return Ok(progressBookmark);
}
/// <summary>
/// Save page against Chapter for logged in user
/// </summary>
/// <param name="progressDto"></param>
/// <returns></returns>
[HttpPost("progress")]
public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Don't let user bookmark past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
if (bookmarkDto.PageNum > chapter.Pages)
// Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(progressDto.ChapterId);
if (progressDto.PageNum > chapter.Pages)
{
return BadRequest("Can't bookmark past max pages");
progressDto.PageNum = chapter.Pages;
}
if (bookmarkDto.PageNum < 0)
if (progressDto.PageNum < 0)
{
return BadRequest("Can't bookmark less than 0");
progressDto.PageNum = 0;
}
try
{
user.Progresses ??= new List<AppUserProgress>();
var userProgress =
user.Progresses.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id);
user.Progresses.FirstOrDefault(x => x.ChapterId == progressDto.ChapterId && x.AppUserId == user.Id);
if (userProgress == null)
{
user.Progresses.Add(new AppUserProgress
{
PagesRead = bookmarkDto.PageNum,
VolumeId = bookmarkDto.VolumeId,
SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId,
BookScrollId = bookmarkDto.BookScrollId,
PagesRead = progressDto.PageNum,
VolumeId = progressDto.VolumeId,
SeriesId = progressDto.SeriesId,
ChapterId = progressDto.ChapterId,
BookScrollId = progressDto.BookScrollId,
LastModified = DateTime.Now
});
}
else
{
userProgress.PagesRead = bookmarkDto.PageNum;
userProgress.SeriesId = bookmarkDto.SeriesId;
userProgress.VolumeId = bookmarkDto.VolumeId;
userProgress.BookScrollId = bookmarkDto.BookScrollId;
userProgress.PagesRead = progressDto.PageNum;
userProgress.SeriesId = progressDto.SeriesId;
userProgress.VolumeId = progressDto.VolumeId;
userProgress.BookScrollId = progressDto.BookScrollId;
userProgress.LastModified = DateTime.Now;
}
@ -277,6 +340,182 @@ namespace API.Controllers
return BadRequest("Could not save progress");
}
/// <summary>
/// Returns a list of bookmarked pages for a given Chapter
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("get-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarks(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForChapter(user.Id, chapterId));
}
/// <summary>
/// Returns a list of all bookmarked pages for a User
/// </summary>
/// <returns></returns>
[HttpGet("get-all-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetAllBookmarks()
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetAllBookmarkDtos(user.Id));
}
/// <summary>
/// Removes all bookmarks for all chapters linked to a Series
/// </summary>
/// <param name="dto"></param>
/// <returns></returns>
[HttpPost("remove-bookmarks")]
public async Task<ActionResult> RemoveBookmarks(RemoveBookmarkForSeriesDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok("Nothing to remove");
try
{
user.Bookmarks = user.Bookmarks.Where(bmk => bmk.SeriesId != dto.SeriesId).ToList();
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when trying to clear bookmarks");
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not clear bookmarks");
}
/// <summary>
/// Returns all bookmarked pages for a given volume
/// </summary>
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("get-volume-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForVolume(int volumeId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForVolume(user.Id, volumeId));
}
/// <summary>
/// Returns all bookmarked pages for a given series
/// </summary>
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("get-series-bookmarks")]
public async Task<ActionResult<IEnumerable<BookmarkDto>>> GetBookmarksForSeries(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok(Array.Empty<BookmarkDto>());
return Ok(await _unitOfWork.UserRepository.GetBookmarkDtosForSeries(user.Id, seriesId));
}
/// <summary>
/// Bookmarks a page against a Chapter
/// </summary>
/// <param name="bookmarkDto"></param>
/// <returns></returns>
[HttpPost("bookmark")]
public async Task<ActionResult> BookmarkPage(BookmarkDto bookmarkDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Don't let user save past total pages.
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(bookmarkDto.ChapterId);
if (bookmarkDto.Page > chapter.Pages)
{
bookmarkDto.Page = chapter.Pages;
}
if (bookmarkDto.Page < 0)
{
bookmarkDto.Page = 0;
}
try
{
user.Bookmarks ??= new List<AppUserBookmark>();
var userBookmark =
user.Bookmarks.SingleOrDefault(x => x.ChapterId == bookmarkDto.ChapterId && x.AppUserId == user.Id && x.Page == bookmarkDto.Page);
if (userBookmark == null)
{
user.Bookmarks.Add(new AppUserBookmark()
{
Page = bookmarkDto.Page,
VolumeId = bookmarkDto.VolumeId,
SeriesId = bookmarkDto.SeriesId,
ChapterId = bookmarkDto.ChapterId,
});
}
else
{
userBookmark.Page = bookmarkDto.Page;
userBookmark.SeriesId = bookmarkDto.SeriesId;
userBookmark.VolumeId = bookmarkDto.VolumeId;
}
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not save bookmark");
}
/// <summary>
/// Removes a bookmarked page for a Chapter
/// </summary>
/// <param name="bookmarkDto"></param>
/// <returns></returns>
[HttpPost("unbookmark")]
public async Task<ActionResult> UnBookmarkPage(BookmarkDto bookmarkDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Bookmarks == null) return Ok();
try {
user.Bookmarks = user.Bookmarks.Where(x =>
x.ChapterId == bookmarkDto.ChapterId
&& x.AppUserId == user.Id
&& x.Page != bookmarkDto.Page).ToList();
_unitOfWork.UserRepository.Update(user);
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
}
catch (Exception)
{
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not remove bookmark");
}
/// <summary>
/// Returns the next logical chapter from the series.
/// </summary>

View File

@ -4,10 +4,12 @@ using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs;
using API.DTOs.Filtering;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Interfaces;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -27,12 +29,12 @@ namespace API.Controllers
_unitOfWork = unitOfWork;
}
[HttpGet]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams)
[HttpPost]
public async Task<ActionResult<IEnumerable<Series>>> GetSeriesForLibrary(int libraryId, [FromQuery] UserParams userParams, [FromBody] FilterDto filterDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series =
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams);
await _unitOfWork.SeriesRepository.GetSeriesDtoForLibraryIdAsync(libraryId, user.Id, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series for library");
@ -44,12 +46,27 @@ namespace API.Controllers
return Ok(series);
}
/// <summary>
/// Fetches a Series for a given Id
/// </summary>
/// <param name="seriesId">Series Id to fetch details for</param>
/// <returns></returns>
/// <exception cref="KavitaException">Throws an exception if the series Id does exist</exception>
[HttpGet("{seriesId}")]
public async Task<ActionResult<SeriesDto>> GetSeries(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
try
{
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoByIdAsync(seriesId, user.Id));
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue fetching {SeriesId}", seriesId);
throw new KavitaException("This series does not exist");
}
}
[Authorize(Policy = "RequireAdminRole")]
[HttpDelete("{seriesId}")]
@ -62,6 +79,9 @@ namespace API.Controllers
if (result)
{
await _unitOfWork.AppUserProgressRepository.CleanupAbandonedChapters();
await _unitOfWork.CollectionTagRepository.RemoveTagsWithoutSeries();
await _unitOfWork.CommitAsync();
_taskScheduler.CleanupChapters(chapterIds);
}
return Ok(result);
@ -119,7 +139,7 @@ namespace API.Controllers
return Ok();
}
[HttpPost]
[HttpPost("update")]
public async Task<ActionResult> UpdateSeries(UpdateSeriesDto updateSeries)
{
_logger.LogInformation("{UserName} is updating Series {SeriesName}", User.GetUsername(), updateSeries.Name);
@ -132,27 +152,39 @@ namespace API.Controllers
{
return BadRequest("A series already exists in this library with this name. Series Names must be unique to a library.");
}
series.Name = updateSeries.Name;
series.LocalizedName = updateSeries.LocalizedName;
series.SortName = updateSeries.SortName;
series.Summary = updateSeries.Summary;
series.Name = updateSeries.Name.Trim();
series.LocalizedName = updateSeries.LocalizedName.Trim();
series.SortName = updateSeries.SortName?.Trim();
series.Summary = updateSeries.Summary?.Trim();
var needsRefreshMetadata = false;
if (series.CoverImageLocked && !updateSeries.CoverImageLocked)
{
// Trigger a refresh when we are moving from a locked image to a non-locked
needsRefreshMetadata = true;
series.CoverImageLocked = updateSeries.CoverImageLocked;
}
_unitOfWork.SeriesRepository.Update(series);
if (await _unitOfWork.CommitAsync())
{
if (needsRefreshMetadata)
{
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
}
return Ok();
}
return BadRequest("There was an error with updating the series");
}
[HttpGet("recently-added")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded([FromQuery] UserParams userParams, int libraryId = 0)
[HttpPost("recently-added")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetRecentlyAdded(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var series =
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams);
await _unitOfWork.SeriesRepository.GetRecentlyAdded(libraryId, user.Id, userParams, filterDto);
// Apply progress/rating information (I can't work out how to do this in initial query)
if (series == null) return BadRequest("Could not get series");
@ -164,12 +196,20 @@ namespace API.Controllers
return Ok(series);
}
[HttpGet("in-progress")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(int libraryId = 0, int limit = 20)
[HttpPost("in-progress")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetInProgress(FilterDto filterDto, [FromQuery] UserParams userParams, [FromQuery] int libraryId = 0)
{
// NOTE: This has to be done manually like this due to the DistinctBy requirement
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Ok(Array.Empty<SeriesDto>());
return Ok(await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, limit));
var results = await _unitOfWork.SeriesRepository.GetInProgress(user.Id, libraryId, userParams, filterDto);
var listResults = results.DistinctBy(s => s.Name).Skip((userParams.PageNumber - 1) * userParams.PageSize)
.Take(userParams.PageSize).ToList();
var pagedList = new PagedList<SeriesDto>(listResults, listResults.Count, userParams.PageNumber, userParams.PageSize);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
return Ok(pagedList);
}
[Authorize(Policy = "RequireAdminRole")]
@ -202,6 +242,7 @@ namespace API.Controllers
{
var seriesId = updateSeriesMetadataDto.SeriesMetadata.SeriesId;
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId);
var allTags = (await _unitOfWork.CollectionTagRepository.GetAllTagsAsync()).ToList();
if (series.Metadata == null)
{
series.Metadata = DbFactory.SeriesMetadata(updateSeriesMetadataDto.Tags
@ -226,13 +267,13 @@ namespace API.Controllers
// At this point, all tags that aren't in dto have been removed.
foreach (var tag in updateSeriesMetadataDto.Tags)
{
var existingTag = series.Metadata.CollectionTags.SingleOrDefault(t => t.Title == tag.Title);
var existingTag = allTags.SingleOrDefault(t => t.Title == tag.Title);
if (existingTag != null)
{
// Update existingTag
existingTag.Promoted = tag.Promoted;
existingTag.Title = tag.Title;
existingTag.NormalizedTitle = Parser.Parser.Normalize(tag.Title).ToUpper();
if (!series.Metadata.CollectionTags.Any(t => t.Title == tag.Title))
{
newTags.Add(existingTag);
}
}
else
{
@ -257,14 +298,21 @@ namespace API.Controllers
return Ok("Successfully updated");
}
}
catch (Exception)
catch (Exception ex)
{
_logger.LogError(ex, "There was an exception when updating metadata");
await _unitOfWork.RollbackAsync();
}
return BadRequest("Could not update metadata");
}
/// <summary>
/// Returns all Series grouped by the passed Collection Id with Pagination.
/// </summary>
/// <param name="collectionId">Collection Id to pull series from</param>
/// <param name="userParams">Pagination information</param>
/// <returns></returns>
[HttpGet("series-by-collection")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetSeriesByCollectionTag(int collectionId, [FromQuery] UserParams userParams)
{
@ -282,6 +330,19 @@ namespace API.Controllers
return Ok(series);
}
/// <summary>
/// Fetches Series for a set of Ids. This will check User for permission access and filter out any Ids that don't exist or
/// the user does not have access to.
/// </summary>
/// <returns></returns>
[HttpPost("series-by-ids")]
public async Task<ActionResult<IEnumerable<SeriesDto>>> GetAllSeriesById(SeriesByIdsDto dto)
{
if (dto.SeriesIds == null) return BadRequest("Must pass seriesIds");
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.SeriesRepository.GetSeriesDtoForIdsAsync(dto.SeriesIds, user.Id));
}
}
}

View File

@ -1,7 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using API.DTOs.Stats;
using API.DTOs.Update;
using API.Extensions;
using API.Interfaces.Services;
using API.Services.Tasks;
@ -23,9 +25,11 @@ namespace API.Controllers
private readonly IBackupService _backupService;
private readonly IArchiveService _archiveService;
private readonly ICacheService _cacheService;
private readonly IVersionUpdaterService _versionUpdaterService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger, IConfiguration config,
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService)
IBackupService backupService, IArchiveService archiveService, ICacheService cacheService,
IVersionUpdaterService versionUpdaterService)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
@ -33,8 +37,13 @@ namespace API.Controllers
_backupService = backupService;
_archiveService = archiveService;
_cacheService = cacheService;
_versionUpdaterService = versionUpdaterService;
}
/// <summary>
/// Attempts to Restart the server. Does not work, will shutdown the instance.
/// </summary>
/// <returns></returns>
[HttpPost("restart")]
public ActionResult RestartServer()
{
@ -44,6 +53,10 @@ namespace API.Controllers
return Ok();
}
/// <summary>
/// Performs an ad-hoc cleanup of Cache
/// </summary>
/// <returns></returns>
[HttpPost("clear-cache")]
public ActionResult ClearCache()
{
@ -53,6 +66,19 @@ namespace API.Controllers
return Ok();
}
/// <summary>
/// Performs an ad-hoc backup of the Database
/// </summary>
/// <returns></returns>
[HttpPost("backup-db")]
public ActionResult BackupDatabase()
{
_logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername());
_backupService.BackupDatabase();
return Ok();
}
/// <summary>
/// Returns non-sensitive information about the current system
/// </summary>
@ -78,6 +104,16 @@ namespace API.Controllers
}
}
[HttpGet("check-update")]
public async Task<ActionResult<UpdateNotificationDto>> CheckForUpdates()
{
return Ok(await _versionUpdaterService.CheckForUpdate());
}
[HttpGet("changelog")]
public async Task<ActionResult<IEnumerable<UpdateNotificationDto>>> GetChangelog()
{
return Ok(await _versionUpdaterService.GetAllReleases());
}
}
}

View File

@ -29,10 +29,9 @@ namespace API.Controllers
return Ok();
}
catch (Exception e)
catch (Exception ex)
{
_logger.LogError(e, "Error updating the usage statistics");
Console.WriteLine(e);
_logger.LogError(ex, "Error updating the usage statistics");
throw;
}
}

View File

@ -0,0 +1,208 @@
using System;
using System.Threading.Tasks;
using API.DTOs.Uploads;
using API.Interfaces;
using API.Interfaces.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace API.Controllers
{
/// <summary>
///
/// </summary>
[Authorize(Policy = "RequireAdminRole")]
public class UploadController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IImageService _imageService;
private readonly ILogger<UploadController> _logger;
private readonly ITaskScheduler _taskScheduler;
/// <inheritdoc />
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger, ITaskScheduler taskScheduler)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
_logger = logger;
_taskScheduler = taskScheduler;
}
/// <summary>
/// Replaces series cover image and locks it with a base64 encoded image
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("series")]
public async Task<ActionResult> UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest("You must pass a url to use");
}
try
{
var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id);
if (bytes.Length > 0)
{
series.CoverImage = bytes;
series.CoverImageLocked = true;
_unitOfWork.SeriesRepository.Update(series);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Series");
}
/// <summary>
/// Replaces collection tag cover image and locks it with a base64 encoded image
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("collection")]
public async Task<ActionResult> UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest("You must pass a url to use");
}
try
{
var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(uploadFileDto.Id);
if (bytes.Length > 0)
{
tag.CoverImage = bytes;
tag.CoverImageLocked = true;
_unitOfWork.CollectionTagRepository.Update(tag);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Collection Tag");
}
/// <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"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("chapter")]
public async Task<ActionResult> UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto)
{
// Check if Url is non empty, request the image and place in temp, then ask image service to handle it.
// See if we can do this all in memory without touching underlying system
if (string.IsNullOrEmpty(uploadFileDto.Url))
{
return BadRequest("You must pass a url to use");
}
try
{
var bytes = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url);
if (bytes.Length > 0)
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
chapter.CoverImage = bytes;
chapter.CoverImageLocked = true;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.SeriesRepository.GetVolumeAsync(chapter.VolumeId);
volume.CoverImage = chapter.CoverImage;
_unitOfWork.VolumeRepository.Update(volume);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Chapter");
}
/// <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 = "RequireAdminRole")]
[HttpPost("reset-chapter-lock")]
public async Task<ActionResult> ResetChapterLock(UploadFileDto uploadFileDto)
{
try
{
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(uploadFileDto.Id);
chapter.CoverImage = Array.Empty<byte>();
chapter.CoverImageLocked = false;
_unitOfWork.ChapterRepository.Update(chapter);
var volume = await _unitOfWork.SeriesRepository.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();
_taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id);
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("Unable to resetting cover lock for Chapter");
}
}
}

View File

@ -2,14 +2,10 @@
{
public class BookmarkDto
{
public int Id { get; set; }
public int Page { get; set; }
public int VolumeId { get; set; }
public int ChapterId { get; set; }
public int PageNum { get; set; }
public int SeriesId { get; set; }
/// <summary>
/// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
/// on pages that combine multiple "chapters".
/// </summary>
public string BookScrollId { get; set; }
public int ChapterId { get; set; }
}
}

View File

@ -2,6 +2,10 @@
namespace API.DTOs
{
/// <summary>
/// A Chapter is the lowest grouping of a reading medium. A Chapter contains a set of MangaFiles which represents the underlying
/// file (abstracted from type).
/// </summary>
public class ChapterDto
{
public int Id { get; init; }
@ -33,6 +37,13 @@ namespace API.DTOs
/// Calculated at API time. Number of pages read for this Chapter for logged in user.
/// </summary>
public int PagesRead { get; set; }
/// <summary>
/// If the Cover Image is locked for this entity
/// </summary>
public bool CoverImageLocked { get; set; }
/// <summary>
/// Volume Id this Chapter belongs to
/// </summary>
public int VolumeId { get; init; }
}
}

View File

@ -6,5 +6,6 @@
public string Title { get; set; }
public string Summary { get; set; }
public bool Promoted { get; set; }
public bool CoverImageLocked { get; set; }
}
}

View File

@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace API.DTOs.Downloads
{
public class DownloadBookmarkDto
{
public IEnumerable<BookmarkDto> Bookmarks { get; set; }
}
}

View File

@ -0,0 +1,10 @@
using API.Entities.Enums;
namespace API.DTOs.Filtering
{
public class FilterDto
{
public MangaFormat? MangaFormat { get; init; } = null;
}
}

15
API/DTOs/ProgressDto.cs Normal file
View File

@ -0,0 +1,15 @@
namespace API.DTOs
{
public class ProgressDto
{
public int VolumeId { get; set; }
public int ChapterId { get; set; }
public int PageNum { get; set; }
public int SeriesId { get; set; }
/// <summary>
/// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
/// on pages that combine multiple "chapters".
/// </summary>
public string BookScrollId { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs
{
public class RemoveBookmarkForSeriesDto
{
public int SeriesId { get; init; }
}
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs
{
public class SeriesByIdsDto
{
public int[] SeriesIds { get; init; }
}
}

View File

@ -12,6 +12,7 @@ namespace API.DTOs
public string SortName { get; init; }
public string Summary { get; init; }
public int Pages { get; init; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// Sum of pages read from linked Volumes. Calculated at API-time.
/// </summary>

View File

@ -0,0 +1,38 @@
namespace API.DTOs.Update
{
/// <summary>
/// Update Notification denoting a new release available for user to update to
/// </summary>
public class UpdateNotificationDto
{
/// <summary>
/// Current installed Version
/// </summary>
public string CurrentVersion { get; init; }
/// <summary>
/// Semver of the release version
/// <example>0.4.3</example>
/// </summary>
public string UpdateVersion { get; init; }
/// <summary>
/// Release body in HTML
/// </summary>
public string UpdateBody { get; init; }
/// <summary>
/// Title of the release
/// </summary>
public string UpdateTitle { get; init; }
/// <summary>
/// Github Url
/// </summary>
public string UpdateUrl { get; init; }
/// <summary>
/// If this install is within Docker
/// </summary>
public bool IsDocker { get; init; }
/// <summary>
/// Is this a pre-release
/// </summary>
public bool IsPrerelease { get; init; }
}
}

View File

@ -10,5 +10,6 @@
public byte[] CoverImage { get; init; }
public int UserRating { get; set; }
public string UserReview { get; set; }
public bool CoverImageLocked { get; set; }
}
}

View File

@ -0,0 +1,14 @@
namespace API.DTOs.Uploads
{
public class UploadFileDto
{
/// <summary>
/// Id of the Entity
/// </summary>
public int Id { get; set; }
/// <summary>
/// Url of the file to download from (can be null)
/// </summary>
public string Url { get; set; }
}
}

View File

@ -0,0 +1,7 @@
namespace API.Data
{
public class BookmarkRepository
{
}
}

View File

@ -0,0 +1,23 @@
using API.Entities;
using API.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace API.Data
{
public class ChapterRepository : IChapterRepository
{
private readonly DataContext _context;
public ChapterRepository(DataContext context)
{
_context = context;
}
public void Update(Chapter chapter)
{
_context.Entry(chapter).State = EntityState.Modified;
}
// TODO: Move over Chapter based queries here
}
}

View File

@ -31,6 +31,28 @@ namespace API.Data
_context.Entry(tag).State = EntityState.Modified;
}
/// <summary>
/// Removes any collection tags without any series
/// </summary>
public async Task<int> RemoveTagsWithoutSeries()
{
var tagsToDelete = await _context.CollectionTag
.Include(c => c.SeriesMetadatas)
.Where(c => c.SeriesMetadatas.Count == 0)
.ToListAsync();
_context.RemoveRange(tagsToDelete);
return await _context.SaveChangesAsync();
}
public async Task<IEnumerable<CollectionTag>> GetAllTagsAsync()
{
return await _context.CollectionTag
.Select(c => c)
.OrderBy(c => c.NormalizedTitle)
.ToListAsync();
}
public async Task<IEnumerable<CollectionTagDto>> GetAllTagDtosAsync()
{
return await _context.CollectionTag

View File

@ -1,4 +1,7 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using API.Entities;
using API.Entities.Interfaces;
using Microsoft.AspNetCore.Identity;
@ -31,6 +34,7 @@ namespace API.Data
public DbSet<AppUserPreferences> AppUserPreferences { get; set; }
public DbSet<SeriesMetadata> SeriesMetadata { get; set; }
public DbSet<CollectionTag> CollectionTag { get; set; }
public DbSet<AppUserBookmark> AppUserBookmark { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
@ -66,5 +70,48 @@ namespace API.Data
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
entity.LastModified = DateTime.Now;
}
private void OnSaveChanges()
{
foreach (var saveEntity in ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified)
.Select(entry => entry.Entity)
.OfType<IHasConcurrencyToken>())
{
saveEntity.OnSavingChanges();
}
}
#region SaveChanges overrides
public override int SaveChanges()
{
this.OnSaveChanges();
return base.SaveChanges();
}
public override int SaveChanges(bool acceptAllChangesOnSuccess)
{
this.OnSaveChanges();
return base.SaveChanges(acceptAllChangesOnSuccess);
}
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default(CancellationToken))
{
this.OnSaveChanges();
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default(CancellationToken))
{
this.OnSaveChanges();
return base.SaveChangesAsync(cancellationToken);
}
#endregion
}
}

View File

@ -66,9 +66,9 @@ namespace API.Data
return new CollectionTag()
{
Id = id,
NormalizedTitle = API.Parser.Parser.Normalize(title).ToUpper(),
Title = title,
Summary = summary,
NormalizedTitle = API.Parser.Parser.Normalize(title?.Trim()).ToUpper(),
Title = title?.Trim(),
Summary = summary?.Trim(),
Promoted = promoted
};
}

View File

@ -20,7 +20,7 @@ namespace API.Data
{
var fileExtensions = await _dbContext.MangaFile
.AsNoTracking()
.Select(x => x.FilePath)
.Select(x => x.FilePath.ToLower())
.Distinct()
.ToArrayAsync();

View File

@ -0,0 +1,913 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210809210326_BookmarkPages")]
partial class BookmarkPages
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", 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>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Bookmarks")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithOne("UserPreferences")
.HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class BookmarkPages : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AppUserBookmark",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Page = table.Column<int>(type: "INTEGER", nullable: false),
VolumeId = table.Column<int>(type: "INTEGER", nullable: false),
SeriesId = table.Column<int>(type: "INTEGER", nullable: false),
ChapterId = table.Column<int>(type: "INTEGER", nullable: false),
AppUserId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AppUserBookmark", x => x.Id);
table.ForeignKey(
name: "FK_AppUserBookmark_AspNetUsers_AppUserId",
column: x => x.AppUserId,
principalTable: "AspNetUsers",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AppUserBookmark_AppUserId",
table: "AppUserBookmark",
column: "AppUserId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AppUserBookmark");
}
}
}

View File

@ -0,0 +1,919 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210813010210_CoverImageLockFieldsPart1")]
partial class CoverImageLockFieldsPart1
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", 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>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Bookmarks")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithOne("UserPreferences")
.HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class CoverImageLockFieldsPart1 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "CollectionTag",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "Series");
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "CollectionTag");
}
}
}

View File

@ -0,0 +1,922 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210814215831_CoverImageLockedFieldsPart2")]
partial class CoverImageLockedFieldsPart2
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", 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>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Bookmarks")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithOne("UserPreferences")
.HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class CoverImageLockedFieldsPart2 : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "CoverImageLocked",
table: "Chapter",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "CoverImageLocked",
table: "Chapter");
}
}
}

View File

@ -0,0 +1,926 @@
// <auto-generated />
using System;
using API.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace API.Data.Migrations
{
[DbContext(typeof(DataContext))]
[Migration("20210817152226_ProgressConcurencyCheck")]
partial class ProgressConcurencyCheck
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedName")
.IsUnique()
.HasDatabaseName("RoleNameIndex");
b.ToTable("AspNetRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AccessFailedCount")
.HasColumnType("INTEGER");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<bool>("EmailConfirmed")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastActive")
.HasColumnType("TEXT");
b.Property<bool>("LockoutEnabled")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("TEXT");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasColumnType("TEXT");
b.Property<string>("PhoneNumber")
.HasColumnType("TEXT");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("INTEGER");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NormalizedEmail")
.HasDatabaseName("EmailIndex");
b.HasIndex("NormalizedUserName")
.IsUnique()
.HasDatabaseName("UserNameIndex");
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<bool>("AutoCloseMenu")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderDarkMode")
.HasColumnType("INTEGER");
b.Property<string>("BookReaderFontFamily")
.HasColumnType("TEXT");
b.Property<int>("BookReaderFontSize")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderLineSpacing")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderMargin")
.HasColumnType("INTEGER");
b.Property<int>("BookReaderReadingDirection")
.HasColumnType("INTEGER");
b.Property<bool>("BookReaderTapToPaginate")
.HasColumnType("INTEGER");
b.Property<int>("PageSplitOption")
.HasColumnType("INTEGER");
b.Property<int>("ReaderMode")
.HasColumnType("INTEGER");
b.Property<int>("ReadingDirection")
.HasColumnType("INTEGER");
b.Property<int>("ScalingOption")
.HasColumnType("INTEGER");
b.Property<bool>("SiteDarkMode")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId")
.IsUnique();
b.ToTable("AppUserPreferences");
});
modelBuilder.Entity("API.Entities.AppUserProgress", 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>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserProgresses");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("Rating")
.HasColumnType("INTEGER");
b.Property<string>("Review")
.HasColumnType("TEXT");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserRating");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<bool>("IsSpecial")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Number")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("Range")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapter");
});
modelBuilder.Entity("API.Entities.CollectionTag", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
b.Property<bool>("Promoted")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Id", "Promoted")
.IsUnique();
b.ToTable("CollectionTag");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime>("LastScanned")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.ToTable("FolderPath");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("CoverImage")
.HasColumnType("TEXT");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<string>("FilePath")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("MangaFile");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<int>("Format")
.HasColumnType("INTEGER");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<int>("LibraryId")
.HasColumnType("INTEGER");
b.Property<string>("LocalizedName")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("NormalizedName")
.HasColumnType("TEXT");
b.Property<string>("OriginalName")
.HasColumnType("TEXT");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<string>("SortName")
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("LibraryId");
b.HasIndex("Name", "NormalizedName", "LocalizedName", "LibraryId", "Format")
.IsUnique();
b.ToTable("Series");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId")
.IsUnique();
b.HasIndex("Id", "SeriesId")
.IsUnique();
b.ToTable("SeriesMetadata");
});
modelBuilder.Entity("API.Entities.ServerSetting", b =>
{
b.Property<int>("Key")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("ServerSetting");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
b.Property<DateTime>("LastModified")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("Pages")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SeriesId");
b.ToTable("Volume");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.Property<int>("AppUsersId")
.HasColumnType("INTEGER");
b.Property<int>("LibrariesId")
.HasColumnType("INTEGER");
b.HasKey("AppUsersId", "LibrariesId");
b.HasIndex("LibrariesId");
b.ToTable("AppUserLibrary");
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.Property<int>("CollectionTagsId")
.HasColumnType("INTEGER");
b.Property<int>("SeriesMetadatasId")
.HasColumnType("INTEGER");
b.HasKey("CollectionTagsId", "SeriesMetadatasId");
b.HasIndex("SeriesMetadatasId");
b.ToTable("CollectionTagSeriesMetadata");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ClaimType")
.HasColumnType("TEXT");
b.Property<string>("ClaimValue")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("ProviderKey")
.HasColumnType("TEXT");
b.Property<string>("ProviderDisplayName")
.HasColumnType("TEXT");
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.Property<int>("UserId")
.HasColumnType("INTEGER");
b.Property<string>("LoginProvider")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Bookmarks")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithOne("UserPreferences")
.HasForeignKey("API.Entities.AppUserPreferences", "AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserProgress", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Progresses")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRating", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Ratings")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserRole", b =>
{
b.HasOne("API.Entities.AppRole", "Role")
.WithMany("UserRoles")
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.AppUser", "User")
.WithMany("UserRoles")
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Role");
b.Navigation("User");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.HasOne("API.Entities.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("API.Entities.FolderPath", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Folders")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.MangaFile", b =>
{
b.HasOne("API.Entities.Chapter", "Chapter")
.WithMany("Files")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.HasOne("API.Entities.Library", "Library")
.WithMany("Series")
.HasForeignKey("LibraryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Library");
});
modelBuilder.Entity("API.Entities.SeriesMetadata", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithOne("Metadata")
.HasForeignKey("API.Entities.SeriesMetadata", "SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.HasOne("API.Entities.Series", "Series")
.WithMany("Volumes")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Series");
});
modelBuilder.Entity("AppUserLibrary", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("AppUsersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.Library", null)
.WithMany()
.HasForeignKey("LibrariesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CollectionTagSeriesMetadata", b =>
{
b.HasOne("API.Entities.CollectionTag", null)
.WithMany()
.HasForeignKey("CollectionTagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("API.Entities.SeriesMetadata", null)
.WithMany()
.HasForeignKey("SeriesMetadatasId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<int>", b =>
{
b.HasOne("API.Entities.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<int>", b =>
{
b.HasOne("API.Entities.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("API.Entities.AppRole", b =>
{
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Progresses");
b.Navigation("Ratings");
b.Navigation("UserPreferences");
b.Navigation("UserRoles");
});
modelBuilder.Entity("API.Entities.Chapter", b =>
{
b.Navigation("Files");
});
modelBuilder.Entity("API.Entities.Library", b =>
{
b.Navigation("Folders");
b.Navigation("Series");
});
modelBuilder.Entity("API.Entities.Series", b =>
{
b.Navigation("Metadata");
b.Navigation("Volumes");
});
modelBuilder.Entity("API.Entities.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace API.Data.Migrations
{
public partial class ProgressConcurencyCheck : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<uint>(
name: "RowVersion",
table: "AppUserProgresses",
type: "INTEGER",
nullable: false,
defaultValue: 0u);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "RowVersion",
table: "AppUserProgresses");
}
}
}

View File

@ -14,7 +14,7 @@ namespace API.Data.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.4");
.HasAnnotation("ProductVersion", "5.0.8");
modelBuilder.Entity("API.Entities.AppRole", b =>
{
@ -118,6 +118,34 @@ namespace API.Data.Migrations
b.ToTable("AspNetUsers");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AppUserId")
.HasColumnType("INTEGER");
b.Property<int>("ChapterId")
.HasColumnType("INTEGER");
b.Property<int>("Page")
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("VolumeId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("AppUserId");
b.ToTable("AppUserBookmark");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.Property<int>("Id")
@ -198,6 +226,10 @@ namespace API.Data.Migrations
b.Property<int>("PagesRead")
.HasColumnType("INTEGER");
b.Property<uint>("RowVersion")
.IsConcurrencyToken()
.HasColumnType("INTEGER");
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
@ -260,6 +292,9 @@ namespace API.Data.Migrations
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
@ -300,6 +335,9 @@ namespace API.Data.Migrations
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<string>("NormalizedTitle")
.HasColumnType("TEXT");
@ -409,6 +447,9 @@ namespace API.Data.Migrations
b.Property<byte[]>("CoverImage")
.HasColumnType("BLOB");
b.Property<bool>("CoverImageLocked")
.HasColumnType("INTEGER");
b.Property<DateTime>("Created")
.HasColumnType("TEXT");
@ -641,6 +682,17 @@ namespace API.Data.Migrations
b.ToTable("AspNetUserTokens");
});
modelBuilder.Entity("API.Entities.AppUserBookmark", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
.WithMany("Bookmarks")
.HasForeignKey("AppUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AppUser");
});
modelBuilder.Entity("API.Entities.AppUserPreferences", b =>
{
b.HasOne("API.Entities.AppUser", "AppUser")
@ -832,6 +884,8 @@ namespace API.Data.Migrations
modelBuilder.Entity("API.Entities.AppUser", b =>
{
b.Navigation("Bookmarks");
b.Navigation("Progresses");
b.Navigation("Ratings");

View File

@ -62,9 +62,9 @@ namespace API.Data
await context.SaveChangesAsync();
// Port and LoggingLevel are managed in appSettings.json. Update the DB values to match
context.ServerSetting.FirstOrDefault(s => s.Key == ServerSettingKey.Port).Value =
context.ServerSetting.First(s => s.Key == ServerSettingKey.Port).Value =
Configuration.Port + string.Empty;
context.ServerSetting.FirstOrDefault(s => s.Key == ServerSettingKey.LoggingLevel).Value =
context.ServerSetting.First(s => s.Key == ServerSettingKey.LoggingLevel).Value =
Configuration.LogLevel + string.Empty;
await context.SaveChangesAsync();

View File

@ -3,6 +3,7 @@ using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.DTOs;
using API.DTOs.Filtering;
using API.Entities;
using API.Extensions;
using API.Helpers;
@ -75,10 +76,11 @@ namespace API.Data
.ToListAsync();
}
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams)
public async Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
var query = _context.Series
.Where(s => s.LibraryId == libraryId)
.Where(s => s.LibraryId == libraryId && formats.Contains(s.Format))
.OrderBy(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
@ -120,7 +122,7 @@ namespace API.Data
private void SortSpecialChapters(IEnumerable<VolumeDto> volumes)
{
foreach (var v in volumes.Where(vdto => vdto.Number == 0))
foreach (var v in volumes.Where(vDto => vDto.Number == 0))
{
v.Chapters = v.Chapters.OrderBy(x => x.Range, _naturalSortComparer).ToList();
}
@ -302,9 +304,12 @@ namespace API.Data
/// <param name="userId"></param>
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
/// <param name="userParams">Contains pagination information</param>
/// <param name="filter">Optional filter on query</param>
/// <returns></returns>
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams)
public async Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter)
{
var formats = filter.GetSqlFilter();
if (libraryId == 0)
{
var userLibraries = _context.Library
@ -315,8 +320,7 @@ namespace API.Data
.ToList();
var allQuery = _context.Series
.Where(s => userLibraries.Contains(s.LibraryId))
.AsNoTracking()
.Where(s => userLibraries.Contains(s.LibraryId) && formats.Contains(s.Format))
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking();
@ -325,10 +329,10 @@ namespace API.Data
}
var query = _context.Series
.Where(s => s.LibraryId == libraryId)
.AsNoTracking()
.Where(s => s.LibraryId == libraryId && formats.Contains(s.Format))
.OrderByDescending(s => s.Created)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsSplitQuery()
.AsNoTracking();
return await PagedList<SeriesDto>.CreateAsync(query, userParams.PageNumber, userParams.PageSize);
@ -338,47 +342,50 @@ namespace API.Data
/// Returns Series that the user has some partial progress on
/// </summary>
/// <param name="userId"></param>
/// <param name="libraryId"></param>
/// <param name="limit"></param>
/// <param name="libraryId">Library to restrict to, if 0, will apply to all libraries</param>
/// <param name="userParams">Pagination information</param>
/// <param name="filter">Optional (default null) filter on query</param>
/// <returns></returns>
public async Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, int limit)
public async Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter)
{
var series = _context.Series
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new
{
Series = s,
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id).Sum(s1 => s1.PagesRead),
progress.AppUserId,
LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id).Max(p => p.LastModified)
});
var formats = filter.GetSqlFilter();
IList<int> userLibraries;
if (libraryId == 0)
{
var userLibraries = _context.Library
userLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(user => user.Id == userId))
.AsNoTracking()
.Select(library => library.Id)
.ToList();
series = series.Where(s => s.AppUserId == userId
&& s.PagesRead > 0
&& s.PagesRead < s.Series.Pages
&& userLibraries.Contains(s.Series.LibraryId));
}
else
{
series = series.Where(s => s.AppUserId == userId
&& s.PagesRead > 0
&& s.PagesRead < s.Series.Pages
&& s.Series.LibraryId == libraryId);
userLibraries = new List<int>() {libraryId};
}
var retSeries = await series
var series = _context.Series
.Where(s => formats.Contains(s.Format) && userLibraries.Contains(s.LibraryId))
.Join(_context.AppUserProgresses, s => s.Id, progress => progress.SeriesId, (s, progress) => new
{
Series = s,
PagesRead = _context.AppUserProgresses.Where(s1 => s1.SeriesId == s.Id && s1.AppUserId == userId).Sum(s1 => s1.PagesRead),
progress.AppUserId,
LastModified = _context.AppUserProgresses.Where(p => p.Id == progress.Id && p.AppUserId == userId).Max(p => p.LastModified)
})
.AsNoTracking();
var retSeries = series.Where(s => s.AppUserId == userId
&& s.PagesRead > 0
&& s.PagesRead < s.Series.Pages)
.OrderByDescending(s => s.LastModified)
.Select(s => s.Series)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.ToListAsync();
.AsSplitQuery()
.AsNoTracking();
return retSeries.DistinctBy(s => s.Name).Take(limit);
// Pagination does not work for this query as when we pull the data back, we get multiple rows of the same series. See controller for pagination code
return await retSeries.ToListAsync();
}
public async Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId)
@ -434,5 +441,21 @@ namespace API.Data
.AsNoTracking()
.ToListAsync();
}
public async Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId)
{
var allowedLibraries = _context.Library
.Include(l => l.AppUsers)
.Where(library => library.AppUsers.Any(x => x.Id == userId))
.Select(l => l.Id);
return await _context.Series
.Where(s => seriesIds.Contains(s.Id) && allowedLibraries.Contains(s.LibraryId))
.OrderBy(s => s.SortName)
.ProjectTo<SeriesDto>(_mapper.ConfigurationProvider)
.AsNoTracking()
.AsSplitQuery()
.ToListAsync();
}
}
}

View File

@ -1,6 +1,7 @@
using System.Threading.Tasks;
using API.Entities;
using API.Interfaces;
using API.Interfaces.Repositories;
using AutoMapper;
using Microsoft.AspNetCore.Identity;
@ -20,7 +21,7 @@ namespace API.Data
}
public ISeriesRepository SeriesRepository => new SeriesRepository(_context, _mapper);
public IUserRepository UserRepository => new UserRepository(_context, _userManager);
public IUserRepository UserRepository => new UserRepository(_context, _userManager, _mapper);
public ILibraryRepository LibraryRepository => new LibraryRepository(_context, _mapper);
public IVolumeRepository VolumeRepository => new VolumeRepository(_context, _mapper);
@ -30,26 +31,48 @@ namespace API.Data
public IAppUserProgressRepository AppUserProgressRepository => new AppUserProgressRepository(_context);
public ICollectionTagRepository CollectionTagRepository => new CollectionTagRepository(_context, _mapper);
public IFileRepository FileRepository => new FileRepository(_context);
public IChapterRepository ChapterRepository => new ChapterRepository(_context);
/// <summary>
/// Commits changes to the DB. Completes the open transaction.
/// </summary>
/// <returns></returns>
public bool Commit()
{
return _context.SaveChanges() > 0;
}
/// <summary>
/// Commits changes to the DB. Completes the open transaction.
/// </summary>
/// <returns></returns>
public async Task<bool> CommitAsync()
{
return await _context.SaveChangesAsync() > 0;
}
/// <summary>
/// Is the DB Context aware of Changes in loaded entities
/// </summary>
/// <returns></returns>
public bool HasChanges()
{
return _context.ChangeTracker.HasChanges();
}
/// <summary>
/// Rollback transaction
/// </summary>
/// <returns></returns>
public async Task<bool> RollbackAsync()
{
await _context.DisposeAsync();
return true;
}
/// <summary>
/// Rollback transaction
/// </summary>
/// <returns></returns>
public bool Rollback()
{
_context.Dispose();

View File

@ -5,6 +5,8 @@ using API.Constants;
using API.DTOs;
using API.Entities;
using API.Interfaces;
using AutoMapper;
using AutoMapper.QueryableExtensions;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
@ -14,11 +16,13 @@ namespace API.Data
{
private readonly DataContext _context;
private readonly UserManager<AppUser> _userManager;
private readonly IMapper _mapper;
public UserRepository(DataContext context, UserManager<AppUser> userManager)
public UserRepository(DataContext context, UserManager<AppUser> userManager, IMapper mapper)
{
_context = context;
_userManager = userManager;
_mapper = mapper;
}
public void Update(AppUser user)
@ -45,6 +49,7 @@ namespace API.Data
{
return await _context.Users
.Include(u => u.Progresses)
.Include(u => u.Bookmarks)
.SingleOrDefaultAsync(x => x.UserName == username);
}
@ -71,6 +76,47 @@ namespace API.Data
.SingleOrDefaultAsync(p => p.AppUser.UserName == username);
}
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.SeriesId == seriesId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.VolumeId == volumeId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId && x.ChapterId == chapterId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId)
{
return await _context.AppUserBookmark
.Where(x => x.AppUserId == userId)
.OrderBy(x => x.Page)
.AsNoTracking()
.ProjectTo<BookmarkDto>(_mapper.ConfigurationProvider)
.ToListAsync();
}
public async Task<IEnumerable<MemberDto>> GetMembersAsync()
{
return await _context.Users

View File

@ -38,7 +38,6 @@ namespace API.Data
.SingleOrDefaultAsync(c => c.Id == chapterId);
}
/// <summary>
/// Returns Chapters for a volume id.
/// </summary>
@ -79,13 +78,30 @@ namespace API.Data
return chapter;
}
public async Task<IList<MangaFile>> GetFilesForChapter(int chapterId)
/// <summary>
/// Returns non-tracked files for a given chapterId
/// </summary>
/// <param name="chapterId"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId)
{
return await _context.MangaFile
.Where(c => chapterId == c.ChapterId)
.AsNoTracking()
.ToListAsync();
}
/// <summary>
/// Returns non-tracked files for a set of chapterIds
/// </summary>
/// <param name="chapterIds"></param>
/// <returns></returns>
public async Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds)
{
return await _context.MangaFile
.Where(c => chapterIds.Contains(c.ChapterId))
.AsNoTracking()
.ToListAsync();
}
public async Task<IList<MangaFile>> GetFilesForVolume(int volumeId)
{

View File

@ -16,10 +16,13 @@ namespace API.Entities
public ICollection<AppUserProgress> Progresses { get; set; }
public ICollection<AppUserRating> Ratings { get; set; }
public AppUserPreferences UserPreferences { get; set; }
public ICollection<AppUserBookmark> Bookmarks { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; set; }
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;

View File

@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace API.Entities
{
/// <summary>
/// Represents a saved page in a Chapter entity for a given user.
/// </summary>
public class AppUserBookmark
{
public int Id { get; set; }
public int Page { get; set; }
public int VolumeId { get; set; }
public int SeriesId { get; set; }
public int ChapterId { get; set; }
// Relationships
[JsonIgnore]
public AppUser AppUser { get; set; }
public int AppUserId { get; set; }
}
}

View File

@ -1,5 +1,6 @@

using System;
using System.ComponentModel.DataAnnotations;
using API.Entities.Interfaces;
namespace API.Entities
@ -7,12 +8,28 @@ namespace API.Entities
/// <summary>
/// Represents the progress a single user has on a given Chapter.
/// </summary>
public class AppUserProgress : IEntityDate
//[Index(nameof(SeriesId), nameof(VolumeId), nameof(ChapterId), nameof(AppUserId), IsUnique = true)]
public class AppUserProgress : IEntityDate, IHasConcurrencyToken
{
/// <summary>
/// Id of Entity
/// </summary>
public int Id { get; set; }
/// <summary>
/// Pages Read for given Chapter
/// </summary>
public int PagesRead { get; set; }
/// <summary>
/// Volume belonging to Chapter
/// </summary>
public int VolumeId { get; set; }
/// <summary>
/// Series belonging to Chapter
/// </summary>
public int SeriesId { get; set; }
/// <summary>
/// Chapter
/// </summary>
public int ChapterId { get; set; }
/// <summary>
/// For Book Reader, represents the nearest passed anchor on the screen that can be used to resume scroll point
@ -21,10 +38,33 @@ namespace API.Entities
public string BookScrollId { get; set; }
// Relationships
/// <summary>
/// Navigational Property for EF. Links to a unique AppUser
/// </summary>
public AppUser AppUser { get; set; }
/// <summary>
/// User this progress belongs to
/// </summary>
public int AppUserId { get; set; }
/// <summary>
/// When this was first created
/// </summary>
public DateTime Created { get; set; }
/// <summary>
/// Last date this was updated
/// </summary>
public DateTime LastModified { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;
}
}
}

View File

@ -24,6 +24,7 @@ namespace API.Entities
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
public bool CoverImageLocked { get; set; }
/// <summary>
/// Total number of pages in all MangaFiles
/// </summary>

View File

@ -1,6 +1,4 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.Entities.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace API.Entities
@ -9,7 +7,7 @@ namespace API.Entities
/// Represents a user entered field that is used as a tagging and grouping mechanism
/// </summary>
[Index(nameof(Id), nameof(Promoted), IsUnique = true)]
public class CollectionTag : IHasConcurrencyToken
public class CollectionTag
{
public int Id { get; set; }
/// <summary>
@ -21,6 +19,10 @@ namespace API.Entities
/// Cover Image for the collection tag
/// </summary>
public byte[] CoverImage { get; set; }
/// <summary>
/// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations.
/// </summary>
public bool CoverImageLocked { get; set; }
/// <summary>
/// A description of the tag
@ -38,10 +40,14 @@ namespace API.Entities
public ICollection<SeriesMetadata> SeriesMetadatas { get; set; }
/// <summary>
/// Not Used due to not using concurrency update
/// </summary>
public uint RowVersion { get; private set; }
[ConcurrencyCheck]
public uint RowVersion { get; set; }
/// <summary>
/// Not Used due to not using concurrency update
/// </summary>
public void OnSavingChanges()
{
RowVersion++;

View File

@ -2,16 +2,36 @@
namespace API.Entities.Enums
{
/// <summary>
/// Represents the format of the file
/// </summary>
public enum MangaFormat
{
/// <summary>
/// Image file
/// See <see cref="Parser.Parser.ImageFileExtensions"/> for supported extensions
/// </summary>
[Description("Image")]
Image = 0,
/// <summary>
/// Archive based file
/// See <see cref="Parser.Parser.ArchiveFileExtensions"/> for supported extensions
/// </summary>
[Description("Archive")]
Archive = 1,
/// <summary>
/// Unknown. Not used.
/// </summary>
[Description("Unknown")]
Unknown = 2,
/// <summary>
/// EPUB File
/// </summary>
[Description("EPUB")]
Epub = 3,
/// <summary>
/// PDF File
/// </summary>
[Description("PDF")]
Pdf = 4
}

View File

@ -9,9 +9,11 @@ namespace API.Entities
public string Name { get; set; }
// MetadataUpdate add ProviderId
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; set; }
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;

View File

@ -5,6 +5,9 @@ using API.Entities.Enums;
namespace API.Entities
{
/// <summary>
/// Represents a wrapper to the underlying file. This provides information around file, like number of pages, format, etc.
/// </summary>
public class MangaFile
{
public int Id { get; set; }

View File

@ -10,9 +10,11 @@ namespace API.Entities
public string Name { get; set; }
public PersonRole Role { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; set; }
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;

View File

@ -38,6 +38,10 @@ namespace API.Entities
public DateTime LastModified { get; set; }
public byte[] CoverImage { get; set; }
/// <summary>
/// Denotes if the CoverImage has been overridden by the user. If so, it will not be updated during normal scan operations.
/// </summary>
public bool CoverImageLocked { get; set; }
/// <summary>
/// Sum of all Volume page counts
/// </summary>
public int Pages { get; set; }

View File

@ -16,9 +16,11 @@ namespace API.Entities
public Series Series { get; set; }
public int SeriesId { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; set; }
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;

View File

@ -10,8 +10,11 @@ namespace API.Entities
public ServerSettingKey Key { get; set; }
public string Value { get; set; }
/// <inheritdoc />
[ConcurrencyCheck]
public uint RowVersion { get; set; }
public uint RowVersion { get; private set; }
/// <inheritdoc />
public void OnSavingChanges()
{
RowVersion++;

View File

@ -4,6 +4,7 @@ using API.Interfaces;
using API.Interfaces.Services;
using API.Services;
using API.Services.Tasks;
using API.SignalR.Presence;
using Kavita.Common;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
@ -32,9 +33,13 @@ namespace API.Extensions
services.AddScoped<ICleanupService, CleanupService>();
services.AddScoped<IBookService, BookService>();
services.AddScoped<IImageService, ImageService>();
services.AddScoped<IVersionUpdaterService, VersionUpdaterService>();
services.AddScoped<IPresenceTracker, PresenceTracker>();
services.AddSqLite(config, env);
services.AddLogging(config);
services.AddSignalR();
}
private static void AddSqLite(this IServiceCollection services, IConfiguration config,

View File

@ -1,5 +1,4 @@
using System;
using System.IO;
using System.IO;
using System.Linq;
using API.Comparators;

View File

@ -5,14 +5,16 @@ namespace API.Extensions
{
public static class FileInfoExtensions
{
public static bool DoesLastWriteMatch(this FileInfo fileInfo, DateTime comparison)
/// <summary>
/// Checks if the last write time of the file is after the passed date
/// </summary>
/// <param name="fileInfo"></param>
/// <param name="comparison"></param>
/// <returns></returns>
public static bool HasFileBeenModifiedSince(this FileInfo fileInfo, DateTime comparison)
{
return comparison.Equals(fileInfo.LastWriteTime);
}
public static bool IsLastWriteLessThan(this FileInfo fileInfo, DateTime comparison)
{
return fileInfo.LastWriteTime < comparison;
return DateTime.Compare(fileInfo.LastWriteTime, comparison) > 0;
//return fileInfo?.LastWriteTime > comparison;
}
}
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
using API.DTOs.Filtering;
using API.Entities.Enums;
namespace API.Extensions
{
public static class FilterDtoExtensions
{
private static readonly IList<MangaFormat> AllFormats = Enum.GetValues<MangaFormat>();
public static IList<MangaFormat> GetSqlFilter(this FilterDto filter)
{
var format = filter.MangaFormat;
if (format != null)
{
return new List<MangaFormat>()
{
(MangaFormat) format
};
}
return AllFormats;
}
}
}

View File

@ -1,4 +1,5 @@
using System.Text;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities;
@ -35,6 +36,22 @@ namespace API.Extensions
ValidateIssuer = false,
ValidateAudience = false
};
options.Events = new JwtBearerEvents()
{
OnMessageReceived = context =>
{
var accessToken = context.Request.Query["access_token"];
var path = context.HttpContext.Request.Path;
// Only use query string based token on SignalR hubs
if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs"))
{
context.Token = accessToken;
}
return Task.CompletedTask;
}
};
});
services.AddAuthorization(opt =>
{

View File

@ -29,6 +29,8 @@ namespace API.Helpers
CreateMap<AppUserPreferences, UserPreferencesDto>();
CreateMap<AppUserBookmark, BookmarkDto>();
CreateMap<Series, SearchResultDto>()
.ForMember(dest => dest.SeriesId,
opt => opt.MapFrom(src => src.Id))

View File

@ -15,5 +15,7 @@ namespace API.Interfaces
Task<CollectionTag> GetTagAsync(int tagId);
Task<CollectionTag> GetFullTagAsync(int tagId);
void Update(CollectionTag tag);
Task<int> RemoveTagsWithoutSeries();
Task<IEnumerable<CollectionTag>> GetAllTagsAsync();
}
}

View File

@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs;
using API.DTOs.Filtering;
using API.Entities;
using API.Helpers;
@ -21,7 +22,7 @@ namespace API.Interfaces
/// <param name="userId"></param>
/// <param name="userParams"></param>
/// <returns></returns>
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams);
Task<PagedList<SeriesDto>> GetSeriesDtoForLibraryIdAsync(int libraryId, int userId, UserParams userParams, FilterDto filter);
/// <summary>
/// Does not add user information like progress, ratings, etc.
@ -57,10 +58,11 @@ namespace API.Interfaces
Task<byte[]> GetVolumeCoverImageAsync(int volumeId);
Task<byte[]> GetSeriesCoverImageAsync(int seriesId);
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, int limit);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams);
Task<IEnumerable<SeriesDto>> GetInProgress(int userId, int libraryId, UserParams userParams, FilterDto filter);
Task<PagedList<SeriesDto>> GetRecentlyAdded(int libraryId, int userId, UserParams userParams, FilterDto filter);
Task<SeriesMetadataDto> GetSeriesMetadata(int seriesId);
Task<PagedList<SeriesDto>> GetSeriesDtoForCollectionAsync(int collectionId, int userId, UserParams userParams);
Task<IList<MangaFile>> GetFilesForSeries(int seriesId);
Task<IEnumerable<SeriesDto>> GetSeriesDtoForIdsAsync(IEnumerable<int> seriesIds, int userId);
}
}

View File

@ -6,13 +6,14 @@
/// For use on Server startup
/// </summary>
void ScheduleTasks();
void ScheduleStatsTasks();
void ScheduleUpdaterTasks();
void ScanLibrary(int libraryId, bool forceUpdate = false);
void CleanupChapters(int[] chapterIds);
void RefreshMetadata(int libraryId, bool forceUpdate = true);
void CleanupTemp();
void RefreshSeriesMetadata(int libraryId, int seriesId);
void ScanSeries(int libraryId, int seriesId, bool forceUpdate = false);
void ScheduleStatsTasks();
void CancelStatsTasks();
}
}

View File

@ -1,4 +1,5 @@
using System.Threading.Tasks;
using API.Interfaces.Repositories;
namespace API.Interfaces
{
@ -12,6 +13,7 @@ namespace API.Interfaces
IAppUserProgressRepository AppUserProgressRepository { get; }
ICollectionTagRepository CollectionTagRepository { get; }
IFileRepository FileRepository { get; }
IChapterRepository ChapterRepository { get; }
bool Commit();
Task<bool> CommitAsync();
bool HasChanges();

View File

@ -16,5 +16,9 @@ namespace API.Interfaces
Task<AppUserRating> GetUserRating(int seriesId, int userId);
void AddRatingTracking(AppUserRating userRating);
Task<AppUserPreferences> GetPreferencesAsync(string username);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForSeries(int userId, int seriesId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForVolume(int userId, int volumeId);
Task<IEnumerable<BookmarkDto>> GetBookmarkDtosForChapter(int userId, int chapterId);
Task<IEnumerable<BookmarkDto>> GetAllBookmarkDtos(int userId);
}
}

View File

@ -10,7 +10,8 @@ namespace API.Interfaces
void Update(Volume volume);
Task<Chapter> GetChapterAsync(int chapterId);
Task<ChapterDto> GetChapterDtoAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChapter(int chapterId);
Task<IList<MangaFile>> GetFilesForChapterAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForChaptersAsync(IReadOnlyList<int> chapterIds);
Task<IList<Chapter>> GetChaptersAsync(int volumeId);
Task<byte[]> GetChapterCoverImageAsync(int chapterId);
Task<IList<MangaFile>> GetFilesForVolume(int volumeId);

View File

@ -0,0 +1,9 @@
using API.Entities;
namespace API.Interfaces.Repositories
{
public interface IChapterRepository
{
void Update(Chapter chapter);
}
}

View File

@ -35,5 +35,7 @@ namespace API.Interfaces.Services
Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page);
void EnsureCacheDirectory();
string GetCachedEpubFile(int chapterId, Chapter chapter);
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files);
}
}

View File

@ -20,7 +20,7 @@ namespace API.Interfaces.Services
/// <returns></returns>
string[] GetFilesWithExtension(string path, string searchPatternExpression = "");
Task<byte[]> ReadFileAsync(string path);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath);
bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "");
bool Exists(string directory);
IEnumerable<string> GetFiles(string path, string searchPatternExpression = "",

View File

@ -6,5 +6,17 @@ namespace API.Interfaces.Services
{
byte[] GetCoverImage(string path, bool createThumbnail = false);
string GetCoverFile(MangaFile file);
/// <summary>
/// Creates a Thumbnail version of an image
/// </summary>
/// <param name="path">Path to the image file</param>
/// <returns></returns>
public byte[] CreateThumbnail(string path);
/// <summary>
/// Creates a Thumbnail version of a base64 image
/// </summary>
/// <param name="encodedImage">base64 encoded image</param>
/// <returns></returns>
public byte[] CreateThumbnailFromBase64(string encodedImage);
}
}

View File

@ -0,0 +1,13 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using API.DTOs.Update;
namespace API.Interfaces.Services
{
public interface IVersionUpdaterService
{
Task<UpdateNotificationDto> CheckForUpdate();
Task PushUpdate(UpdateNotificationDto update);
Task<IEnumerable<UpdateNotificationDto>> GetAllReleases();
}
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@ -16,6 +16,7 @@ namespace API.Parser
public const string ImageFileExtensions = @"^(\.png|\.jpeg|\.jpg)";
public const string ArchiveFileExtensions = @"\.cbz|\.zip|\.rar|\.cbr|\.tar.gz|\.7zip|\.7z|\.cb7|\.cbt";
public const string BookFileExtensions = @"\.epub|\.pdf";
public const string MacOsMetadataFileStartsWith = @"._";
public const string SupportedExtensions =
ArchiveFileExtensions + "|" + ImageFileExtensions + "|" + BookFileExtensions;
@ -551,6 +552,8 @@ namespace API.Parser
};
}
if (IsImage(filePath) && IsCoverImage(fileName)) return null;
if (IsImage(filePath))
{
// Reset Chapters, Volumes, and Series as images are not good to parse information out of. Better to use folders.
@ -1026,6 +1029,7 @@ namespace API.Parser
return Regex.Replace(name.ToLower(), "[^a-zA-Z0-9]", string.Empty);
}
/// <summary>
/// Tests whether the file is a cover image such that: contains "cover", is named "folder", and is an image
/// </summary>

View File

@ -30,6 +30,7 @@ namespace API.Services
private readonly IDirectoryService _directoryService;
private static readonly RecyclableMemoryStreamManager StreamManager = new();
private readonly NaturalSortComparer _comparer;
private const string ComicInfoFilename = "comicinfo";
public ArchiveService(ILogger<ArchiveService> logger, IDirectoryService directoryService)
{
@ -117,7 +118,8 @@ namespace API.Services
{
var result = entryFullNames
.FirstOrDefault(x => !Path.EndsInDirectorySeparator(x) && !Parser.Parser.HasBlacklistedFolderInPath(x)
&& Parser.Parser.IsCoverImage(x));
&& Parser.Parser.IsCoverImage(x)
&& !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
return string.IsNullOrEmpty(result) ? null : result;
}
@ -131,7 +133,8 @@ namespace API.Services
{
var result = entryFullNames.OrderBy(Path.GetFileName, _comparer)
.FirstOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x)
&& Parser.Parser.IsImage(x));
&& Parser.Parser.IsImage(x)
&& !x.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
return string.IsNullOrEmpty(result) ? null : result;
}
@ -222,17 +225,16 @@ namespace API.Services
public async Task<Tuple<byte[], string>> CreateZipForDownload(IEnumerable<string> files, string tempFolder)
{
var tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
var dateString = DateTime.Now.ToShortDateString().Replace("/", "_");
var tempLocation = Path.Join(tempDirectory, $"{tempFolder}_{dateString}");
var tempLocation = Path.Join(DirectoryService.TempDirectory, $"{tempFolder}_{dateString}");
DirectoryService.ExistOrCreate(tempLocation);
if (!_directoryService.CopyFilesToDirectory(files, tempLocation))
{
throw new KavitaException("Unable to copy files to temp directory archive download.");
}
var zipPath = Path.Join(tempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
var zipPath = Path.Join(DirectoryService.TempDirectory, $"kavita_{tempFolder}_{dateString}.zip");
try
{
ZipFile.CreateFromDirectory(tempLocation, zipPath);
@ -295,7 +297,11 @@ namespace API.Services
{
foreach (var entry in entries)
{
if (Path.GetFileNameWithoutExtension(entry.Key).ToLower().EndsWith("comicinfo") && !Parser.Parser.HasBlacklistedFolderInPath(entry.Key) && Parser.Parser.IsXml(entry.Key))
var filename = Path.GetFileNameWithoutExtension(entry.Key).ToLower();
if (filename.EndsWith(ComicInfoFilename)
&& !filename.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
&& !Parser.Parser.HasBlacklistedFolderInPath(entry.Key)
&& Parser.Parser.IsXml(entry.Key))
{
using var ms = StreamManager.GetStream();
entry.WriteTo(ms);
@ -328,7 +334,10 @@ namespace API.Services
{
_logger.LogDebug("Using default compression handling");
using var archive = ZipFile.OpenRead(archivePath);
var entry = archive.Entries.SingleOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName) && Path.GetFileNameWithoutExtension(x.Name).ToLower() == "comicinfo" && Parser.Parser.IsXml(x.FullName));
var entry = archive.Entries.SingleOrDefault(x => !Parser.Parser.HasBlacklistedFolderInPath(x.FullName)
&& Path.GetFileNameWithoutExtension(x.Name)?.ToLower() == ComicInfoFilename
&& !Path.GetFileNameWithoutExtension(x.Name).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
&& Parser.Parser.IsXml(x.FullName));
if (entry != null)
{
using var stream = entry.Open();
@ -343,6 +352,7 @@ namespace API.Services
using var archive = ArchiveFactory.Open(archivePath);
info = FindComicInfoXml(archive.Entries.Where(entry => !entry.IsDirectory
&& !Parser.Parser.HasBlacklistedFolderInPath(Path.GetDirectoryName(entry.Key) ?? string.Empty)
&& !Path.GetFileNameWithoutExtension(entry.Key).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith)
&& Parser.Parser.IsXml(entry.Key)));
break;
}

View File

@ -21,8 +21,6 @@ using HtmlAgilityPack;
using Microsoft.Extensions.Logging;
using Microsoft.IO;
using VersOne.Epub;
using Image = NetVips.Image;
using Point = System.Drawing.Point;
namespace API.Services
{
@ -325,10 +323,10 @@ namespace API.Services
Edition = string.Empty,
Format = MangaFormat.Epub,
Filename = Path.GetFileName(filePath),
Title = specialName,
Title = specialName.Trim(),
FullFilePath = filePath,
IsSpecial = false,
Series = series,
Series = series.Trim(),
Volumes = seriesIndex.Split(".")[0]
};
}
@ -344,10 +342,10 @@ namespace API.Services
Edition = string.Empty,
Format = MangaFormat.Epub,
Filename = Path.GetFileName(filePath),
Title = epubBook.Title,
Title = epubBook.Title.Trim(),
FullFilePath = filePath,
IsSpecial = false,
Series = epubBook.Title,
Series = epubBook.Title.Trim(),
Volumes = Parser.Parser.DefaultVolume
};
}
@ -409,7 +407,7 @@ namespace API.Services
if (!createThumbnail) return coverImageContent.ReadContent();
using var stream = StreamManager.GetStream("BookService.GetCoverImage", coverImageContent.ReadContent());
using var thumbnail = Image.ThumbnailStream(stream, MetadataService.ThumbnailWidth);
using var thumbnail = NetVips.Image.ThumbnailStream(stream, MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
}
@ -433,7 +431,7 @@ namespace API.Services
if (!createThumbnail) return stream.ToArray();
using var thumbnail = Image.ThumbnailStream(stream, MetadataService.ThumbnailWidth);
using var thumbnail = NetVips.Image.ThumbnailStream(stream, MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".png");
}

View File

@ -36,26 +36,61 @@ namespace API.Services
public void EnsureCacheDirectory()
{
if (!DirectoryService.ExistOrCreate(CacheDirectory))
if (!DirectoryService.ExistOrCreate(DirectoryService.CacheDirectory))
{
_logger.LogError("Cache directory {CacheDirectory} is not accessible or does not exist. Creating...", CacheDirectory);
}
}
/// <summary>
/// Returns the full path to the cached epub file. If the file does not exist, will fallback to the original.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="chapter"></param>
/// <returns></returns>
public string GetCachedEpubFile(int chapterId, Chapter chapter)
{
var extractPath = GetCachePath(chapterId);
var path = Path.Join(extractPath, Path.GetFileName(chapter.Files.First().FilePath));
if (!(new FileInfo(path).Exists))
{
path = chapter.Files.First().FilePath;
}
return path;
}
/// <summary>
/// Caches the files for the given chapter to CacheDirectory
/// </summary>
/// <param name="chapterId"></param>
/// <returns>This will always return the Chapter for the chpaterId</returns>
public async Task<Chapter> Ensure(int chapterId)
{
EnsureCacheDirectory();
var chapter = await _unitOfWork.VolumeRepository.GetChapterAsync(chapterId);
var files = chapter.Files.ToList();
var fileCount = files.Count;
var extractPath = GetCachePath(chapterId);
var extraPath = "";
if (Directory.Exists(extractPath))
if (!Directory.Exists(extractPath))
{
var files = chapter.Files.ToList();
ExtractChapterFiles(extractPath, files);
}
return chapter;
}
/// <summary>
/// This is an internal method for cache service for extracting chapter files to disk. The code is structured
/// for cache service, but can be re-used (download bookmarks)
/// </summary>
/// <param name="extractPath"></param>
/// <param name="files"></param>
/// <returns></returns>
public void ExtractChapterFiles(string extractPath, IReadOnlyList<MangaFile> files)
{
var removeNonImages = true;
var fileCount = files.Count;
var extraPath = "";
var extractDi = new DirectoryInfo(extractPath);
if (files.Count > 0 && files[0].Format == MangaFormat.Image)
@ -67,11 +102,11 @@ namespace API.Services
}
else
{
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath, Parser.Parser.ImageFileExtensions);
_directoryService.CopyDirectoryToDirectory(Path.GetDirectoryName(files[0].FilePath), extractPath,
Parser.Parser.ImageFileExtensions);
}
extractDi.Flatten();
return chapter;
}
foreach (var file in files)
@ -84,16 +119,24 @@ namespace API.Services
if (file.Format == MangaFormat.Archive)
{
_archiveService.ExtractArchive(file.FilePath, Path.Join(extractPath, extraPath));
} else if (file.Format == MangaFormat.Pdf)
}
else if (file.Format == MangaFormat.Pdf)
{
_bookService.ExtractPdfImages(file.FilePath, Path.Join(extractPath, extraPath));
}
else if (file.Format == MangaFormat.Epub)
{
removeNonImages = false;
DirectoryService.ExistOrCreate(extractPath);
_directoryService.CopyFileToDirectory(files[0].FilePath, extractPath);
}
}
extractDi.Flatten();
if (removeNonImages)
{
extractDi.RemoveNonImages();
return chapter;
}
}
@ -114,9 +157,13 @@ namespace API.Services
_logger.LogInformation("Cache directory purged");
}
/// <summary>
/// Removes the cached files and folders for a set of chapterIds
/// </summary>
/// <param name="chapterIds"></param>
public void CleanupChapters(IEnumerable<int> chapterIds)
{
_logger.LogInformation("Running Cache cleanup on Volumes");
_logger.LogInformation("Running Cache cleanup on Chapters");
foreach (var chapter in chapterIds)
{
@ -138,14 +185,14 @@ namespace API.Services
/// <returns></returns>
private string GetCachePath(int chapterId)
{
return Path.GetFullPath(Path.Join(CacheDirectory, $"{chapterId}/"));
return Path.GetFullPath(Path.Join(DirectoryService.CacheDirectory, $"{chapterId}/"));
}
public async Task<(string path, MangaFile file)> GetCachedPagePath(Chapter chapter, int page)
{
// Calculate what chapter the page belongs to
var pagesSoFar = 0;
var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapter(chapter.Id);
var chapterFiles = chapter.Files ?? await _unitOfWork.VolumeRepository.GetFilesForChapterAsync(chapter.Id);
foreach (var mangaFile in chapterFiles)
{
if (page <= (mangaFile.Pages + pagesSoFar))

View File

@ -11,7 +11,9 @@ namespace API.Services.Clients
{
private readonly HttpClient _client;
private readonly ILogger<StatsApiClient> _logger;
#pragma warning disable S1075
private const string ApiUrl = "http://stats.kavitareader.com";
#pragma warning restore S1075
public StatsApiClient(HttpClient client, ILogger<StatsApiClient> logger)
{
@ -39,16 +41,12 @@ namespace API.Services.Clients
response = responseContent
};
_logger.LogError(e, "The StatsServer did not respond successfully. {Content}", info);
Console.WriteLine(e);
_logger.LogError(e, "KavitaStats did not respond successfully. {Content}", info);
throw;
}
catch (Exception e)
{
_logger.LogError(e, "An error happened during the request to the Stats Server");
Console.WriteLine(e);
_logger.LogError(e, "An error happened during the request to KavitaStats");
throw;
}
}

View File

@ -16,6 +16,9 @@ namespace API.Services
private static readonly Regex ExcludeDirectories = new Regex(
@"@eaDir|\.DS_Store",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static readonly string TempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
public static readonly string LogDirectory = Path.Join(Directory.GetCurrentDirectory(), "logs");
public static readonly string CacheDirectory = Path.Join(Directory.GetCurrentDirectory(), "cache");
public DirectoryService(ILogger<DirectoryService> logger)
{
@ -25,6 +28,7 @@ namespace API.Services
/// <summary>
/// Given a set of regex search criteria, get files in the given path.
/// </summary>
/// <remarks>This will always exclude <see cref="Parser.Parser.MacOsMetadataFileStartsWith"/> patterns</remarks>
/// <param name="path">Directory to search</param>
/// <param name="searchPatternExpression">Regex version of search pattern (ie \.mp3|\.mp4). Defaults to * meaning all files.</param>
/// <param name="searchOption">SearchOption to use, defaults to TopDirectoryOnly</param>
@ -35,9 +39,10 @@ namespace API.Services
{
if (!Directory.Exists(path)) return ImmutableList<string>.Empty;
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
return Directory.EnumerateFiles(path, "*", searchOption)
.Where(file =>
reSearchPattern.IsMatch(Path.GetExtension(file)));
reSearchPattern.IsMatch(Path.GetExtension(file)) && !Path.GetFileName(file).StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
}
@ -98,18 +103,25 @@ namespace API.Services
var reSearchPattern = new Regex(searchPatternExpression, RegexOptions.IgnoreCase);
return Directory.EnumerateFiles(path, "*", searchOption)
.Where(file =>
reSearchPattern.IsMatch(file));
reSearchPattern.IsMatch(file) && !file.StartsWith(Parser.Parser.MacOsMetadataFileStartsWith));
}
return !Directory.Exists(path) ? Array.Empty<string>() : Directory.GetFiles(path);
}
public void CopyFileToDirectory(string fullFilePath, string targetDirectory)
{
try
{
var fileInfo = new FileInfo(fullFilePath);
if (fileInfo.Exists)
{
fileInfo.CopyTo(Path.Join(targetDirectory, fileInfo.Name));
fileInfo.CopyTo(Path.Join(targetDirectory, fileInfo.Name), true);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "There was a critical error when copying {File} to {Directory}", fullFilePath, targetDirectory);
}
}
@ -238,8 +250,16 @@ namespace API.Services
}
}
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath)
/// <summary>
/// Copies files to a destination directory. If the destination directory doesn't exist, this will create it.
/// </summary>
/// <param name="filePaths"></param>
/// <param name="directoryPath"></param>
/// <param name="prepend">An optional string to prepend to the target file's name</param>
/// <returns></returns>
public bool CopyFilesToDirectory(IEnumerable<string> filePaths, string directoryPath, string prepend = "")
{
ExistOrCreate(directoryPath);
string currentFile = null;
try
{
@ -249,13 +269,12 @@ namespace API.Services
var fileInfo = new FileInfo(file);
if (fileInfo.Exists)
{
fileInfo.CopyTo(Path.Join(directoryPath, fileInfo.Name));
fileInfo.CopyTo(Path.Join(directoryPath, prepend + fileInfo.Name));
}
else
{
_logger.LogWarning("Tried to copy {File} but it doesn't exist", file);
}
}
}
catch (Exception ex)
@ -301,7 +320,7 @@ namespace API.Services
var fileCount = 0;
// Determine whether to parallelize file processing on each folder based on processor count.
var procCount = Environment.ProcessorCount;
//var procCount = Environment.ProcessorCount;
// Data structure to hold names of subfolders to be examined for files.
var dirs = new Stack<string>();
@ -395,5 +414,23 @@ namespace API.Services
return fileCount;
}
/// <summary>
/// Attempts to delete the files passed to it. Swallows exceptions.
/// </summary>
/// <param name="files">Full path of files to delete</param>
public static void DeleteFiles(IEnumerable<string> files)
{
foreach (var file in files)
{
try
{
new FileInfo(file).Delete();
}
catch (Exception)
{
/* Swallow exception */
}
}
}
}
}

View File

@ -23,6 +23,7 @@ namespace API.Services.HostedServices
var taskScheduler = scope.ServiceProvider.GetRequiredService<ITaskScheduler>();
taskScheduler.ScheduleTasks();
taskScheduler.ScheduleUpdaterTasks();
try
{

View File

@ -51,13 +51,13 @@ namespace API.Services
{
if (createThumbnail)
{
using var thumbnail = Image.Thumbnail(path, MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
return CreateThumbnail(path);
}
using var img = Image.NewFromFile(path);
using var stream = new MemoryStream();
img.JpegsaveStream(stream);
stream.Position = 0;
return stream.ToArray();
}
catch (Exception ex)
@ -67,5 +67,39 @@ namespace API.Services
return Array.Empty<byte>();
}
/// <inheritdoc />
public byte[] CreateThumbnail(string path)
{
try
{
using var thumbnail = Image.Thumbnail(path, MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
}
catch (Exception e)
{
_logger.LogError(e, "Error creating thumbnail from url");
}
return Array.Empty<byte>();
}
/// <inheritdoc />
public byte[] CreateThumbnailFromBase64(string encodedImage)
{
try
{
using var thumbnail = Image.ThumbnailBuffer(Convert.FromBase64String(encodedImage), MetadataService.ThumbnailWidth);
return thumbnail.WriteToBuffer(".jpg");
}
catch (Exception e)
{
_logger.LogError(e, "Error creating thumbnail from url");
}
return Array.Empty<byte>();
}
}
}

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
@ -22,6 +22,9 @@ namespace API.Services
private readonly IBookService _bookService;
private readonly IImageService _imageService;
private readonly ChapterSortComparer _chapterSortComparer = new ChapterSortComparer();
/// <summary>
/// Width of the Thumbnail generation
/// </summary>
public static readonly int ThumbnailWidth = 320; // 153w x 230h
public MetadataService(IUnitOfWork unitOfWork, ILogger<MetadataService> logger,
@ -34,13 +37,35 @@ namespace API.Services
_imageService = imageService;
}
private static bool ShouldFindCoverImage(byte[] coverImage, bool forceUpdate = false)
private static bool IsCoverImageSet(byte[] coverImage, bool forceUpdate = false)
{
return forceUpdate || coverImage == null || !coverImage.Any();
return forceUpdate || HasCoverImage(coverImage);
}
/// <summary>
/// Determines whether an entity should regenerate cover image
/// </summary>
/// <param name="coverImage"></param>
/// <param name="firstFile"></param>
/// <param name="forceUpdate"></param>
/// <param name="isCoverLocked"></param>
/// <returns></returns>
public static bool ShouldUpdateCoverImage(byte[] coverImage, MangaFile firstFile, bool forceUpdate = false,
bool isCoverLocked = false)
{
if (isCoverLocked) return false;
if (forceUpdate) return true;
return (firstFile != null && firstFile.HasFileBeenModified()) || !HasCoverImage(coverImage);
}
private static bool HasCoverImage(byte[] coverImage)
{
return coverImage != null && coverImage.Any();
}
private byte[] GetCoverImage(MangaFile file, bool createThumbnail = true)
{
file.LastModified = DateTime.Now;
switch (file.Format)
{
case MangaFormat.Pdf:
@ -54,45 +79,50 @@ namespace API.Services
default:
return Array.Empty<byte>();
}
}
/// <summary>
/// Updates the metadata for a Chapter
/// </summary>
/// <param name="chapter"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Chapter chapter, bool forceUpdate)
{
var firstFile = chapter.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (ShouldFindCoverImage(chapter.CoverImage, forceUpdate) && firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
if (ShouldUpdateCoverImage(chapter.CoverImage, firstFile, forceUpdate, chapter.CoverImageLocked))
{
chapter.Files ??= new List<MangaFile>();
chapter.CoverImage = GetCoverImage(firstFile);
}
}
/// <summary>
/// Updates the metadata for a Volume
/// </summary>
/// <param name="volume"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Volume volume, bool forceUpdate)
{
if (volume == null || !ShouldFindCoverImage(volume.CoverImage, forceUpdate)) return;
if (volume == null || !IsCoverImageSet(volume.CoverImage, forceUpdate)) return;
volume.Chapters ??= new List<Chapter>();
var firstChapter = volume.Chapters.OrderBy(x => double.Parse(x.Number), _chapterSortComparer).FirstOrDefault();
// Skip calculating Cover Image (I/O) if the chapter already has it set
if (firstChapter == null || ShouldFindCoverImage(firstChapter.CoverImage, forceUpdate))
{
var firstFile = firstChapter?.Files.OrderBy(x => x.Chapter).FirstOrDefault();
if (firstFile != null && !new FileInfo(firstFile.FilePath).IsLastWriteLessThan(firstFile.LastModified))
{
volume.CoverImage = GetCoverImage(firstFile);
}
}
else
{
if (firstChapter == null) return;
volume.CoverImage = firstChapter.CoverImage;
}
}
/// <summary>
/// Updates metadata for Series
/// </summary>
/// <param name="series"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void UpdateMetadata(Series series, bool forceUpdate)
{
if (series == null) return;
if (ShouldFindCoverImage(series.CoverImage, forceUpdate))
if (ShouldUpdateCoverImage(series.CoverImage, null, forceUpdate, series.CoverImageLocked))
{
series.Volumes ??= new List<Volume>();
var firstCover = series.Volumes.GetCoverImage(series.Format);
@ -106,7 +136,7 @@ namespace API.Services
.FirstOrDefault(c => !c.IsSpecial)?.CoverImage;
}
if (coverImage == null)
if (!HasCoverImage(coverImage))
{
coverImage = series.Volumes[0].Chapters.OrderBy(c => double.Parse(c.Number), _chapterSortComparer)
.FirstOrDefault()?.CoverImage;
@ -140,12 +170,18 @@ namespace API.Services
}
/// <summary>
/// Refreshes Metadata for a whole library
/// </summary>
/// <remarks>This can be heavy on memory first run</remarks>
/// <param name="libraryId"></param>
/// <param name="forceUpdate">Force updating cover image even if underlying file has not been modified or chapter already has a cover image</param>
public void RefreshMetadata(int libraryId, bool forceUpdate = false)
{
var sw = Stopwatch.StartNew();
var library = Task.Run(() => _unitOfWork.LibraryRepository.GetFullLibraryForIdAsync(libraryId)).GetAwaiter().GetResult();
// TODO: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used
// PERF: See if we can break this up into multiple threads that process 20 series at a time then save so we can reduce amount of memory used
_logger.LogInformation("Beginning metadata refresh of {LibraryName}", library.Name);
foreach (var series in library.Series)
{
@ -171,6 +207,11 @@ namespace API.Services
}
/// <summary>
/// Refreshes Metadata for a Series. Will always force updates.
/// </summary>
/// <param name="libraryId"></param>
/// <param name="seriesId"></param>
public void RefreshMetadataForSeries(int libraryId, int seriesId)
{
var sw = Stopwatch.StartNew();

View File

@ -21,13 +21,14 @@ namespace API.Services
private readonly ICleanupService _cleanupService;
private readonly IStatsService _statsService;
private readonly IVersionUpdaterService _versionUpdaterService;
public static BackgroundJobServer Client => new BackgroundJobServer();
public TaskScheduler(ICacheService cacheService, ILogger<TaskScheduler> logger, IScannerService scannerService,
IUnitOfWork unitOfWork, IMetadataService metadataService, IBackupService backupService,
ICleanupService cleanupService, IStatsService statsService)
ICleanupService cleanupService, IStatsService statsService, IVersionUpdaterService versionUpdaterService)
{
_cacheService = cacheService;
_logger = logger;
@ -37,6 +38,7 @@ namespace API.Services
_backupService = backupService;
_cleanupService = cleanupService;
_statsService = statsService;
_versionUpdaterService = versionUpdaterService;
}
public void ScheduleTasks()
@ -68,6 +70,8 @@ namespace API.Services
}
RecurringJob.AddOrUpdate("cleanup", () => _cleanupService.Cleanup(), Cron.Daily);
RecurringJob.AddOrUpdate("check-for-updates", () => _scannerService.ScanLibraries(), Cron.Daily);
}
#region StatsTasks
@ -97,6 +101,16 @@ namespace API.Services
#endregion
#region UpdateTasks
public void ScheduleUpdaterTasks()
{
_logger.LogInformation("Scheduling Auto-Update tasks");
RecurringJob.AddOrUpdate("check-updates", () => CheckForUpdate(), Cron.Weekly);
}
#endregion
public void ScanLibrary(int libraryId, bool forceUpdate = false)
{
_logger.LogInformation("Enqueuing library scan for: {LibraryId}", libraryId);
@ -138,5 +152,14 @@ namespace API.Services
{
BackgroundJob.Enqueue(() => _backupService.BackupDatabase());
}
/// <summary>
/// Not an external call. Only public so that we can call this for a Task
/// </summary>
public async Task CheckForUpdate()
{
var update = await _versionUpdaterService.CheckForUpdate();
await _versionUpdaterService.PushUpdate(update);
}
}
}

View File

@ -20,6 +20,7 @@ namespace API.Services.Tasks
private readonly ILogger<BackupService> _logger;
private readonly IDirectoryService _directoryService;
private readonly string _tempDirectory = Path.Join(Directory.GetCurrentDirectory(), "temp");
private readonly string _logDirectory = Path.Join(Directory.GetCurrentDirectory(), "logs");
private readonly IList<string> _backupFiles;
@ -53,7 +54,7 @@ namespace API.Services.Tasks
var fi = new FileInfo(logFileName);
var files = maxRollingFiles > 0
? _directoryService.GetFiles(Directory.GetCurrentDirectory(), $@"{fi.Name}{multipleFileRegex}\.log")
? _directoryService.GetFiles(_logDirectory, $@"{Path.GetFileNameWithoutExtension(fi.Name)}{multipleFileRegex}\.log")
: new[] {"kavita.log"};
return files;
}

View File

@ -21,6 +21,9 @@ namespace API.Services.Tasks
_backupService = backupService;
}
/// <summary>
/// Cleans up Temp, cache, and old database backups
/// </summary>
[AutomaticRetry(Attempts = 3, LogEvents = false, OnAttemptsExceeded = AttemptsExceededAction.Fail)]
public void Cleanup()
{
@ -31,7 +34,6 @@ namespace API.Services.Tasks
_cacheService.Cleanup();
_logger.LogInformation("Cleaning old database backups");
_backupService.CleanupBackups();
}
}
}

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