v0.7 - Who doesn't like Stats? (#1798)

* WebP Covers + Series Detail Enhancements (#1652)

* Implemented save covers as webp. Reworked screen to provide more information up front about webp and what browsers can support it.

* cleaned up pages to use compact numbering and made compact numbering expand into one decimal place (20.5K)

* Fixed an issue with adding new device

* If a book has an invalid language set, drop the language altogether rather than reading in a corrupted entry.

* Ensure genres and tags render alphabetically.

Improved support for partial volumes in Comic parser.

* Ensure all people, tags, collections, and genres are in alphabetical order.

* Moved some code to Extensions to clean up code.

* More unit tests

* Cleaned up release year filter css

* Tweaked some code in all series to make bulk deletes cleaner on the UI.

* Trying out want to read and unread count on series detail page

* Added Want to Read button for series page to make it easy to see when something is in want to read list and toggle it.

Added tooltips instead of title to buttons, but they don't style correctly.

Added a continue point under cover image.

* Code smells

* Bump versions by dotnet-bump-version.

* Fixed Series Relations Schema (#1654)

* Bump loader-utils from 2.0.2 to 2.0.3 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.2 to 2.0.3.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.3/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.2...v2.0.3)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed is want to read coming back as a string and not working correctly.

* Changed from to Continue to be more explicit

* Added the first migration which exports data as a csv in temp/. This is the backup in case data is lost in the migration.

* Note for later

* Fixed the migration for the series relation so when deleting any series on any edge of the relationship, the SeriesRelation row deletes.

* Change buttons back to titles on series detail page

* Wrote the code to import relations from the backup.

* Added an additional version check to avoid file io on migration.

* Code cleanup

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* Fresh Nightly Installs Work (#1659)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Removed a very old cgecj from Nov 2021 when data/appsettings was moved to config/

* Added some notes about migration

* Removed a file that shouldn't have been there.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* Library Settings Modal + New Library Settings (#1660)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed want to read button on series detail not performing the correct action

* Started the library settings. Added ability to update a cover image for a library.

Updated backup db to also copy reading list (and now library) cover images.

* Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library.

* Fixed a missing update event in backend when updating a library.

* Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid.

* Trim library names before you check anything

* General code cleanup

* Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries.

Refactored some code to streamline perf in some flows.

* Removed old components replaced with new modal

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* UX Alignment and bugfixes (#1663)

* Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter.

Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop.

* Removed a bug marker that I just fixed

* When generating library covers, make them much smaller as they are only ever icons.

* Fixed library settings not showing the correct image.

* Fixed a bug where duplicate collection tags could be created.

Fixed a bug where collection tag normalized title was being set to uppercase.

Redesigned the edit collection tag modal to align with new library settings and provide inline name checks.

* Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names.

Don't show Continue point on series detail if the whole series is read.

* Added some more unit tests around continue point

* Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab.

* Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting.

Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build).

* Test GA

* Reverted GA and instead do it in the build step. This will just force developers to commit it in.

* GA please work

* Removed redundant steps from test since build already does it.

* Try another GA

* Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes.

* Fixed env variable

* Okay not possible to do secrets in if statement

* Fixed the build step to output the openapi.json where it's expected.

* Fixed GA (#1664)

* Bump versions by dotnet-bump-version.

* Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666)

* [Experimental] Split Renderers - Double & Double (Manga) fixes (#1667)

* Updated swiper and some packages for reported security issues

* Fixed reading lists promotion not working

* Refactor RenameFileForCopy to use iterative recursion, rather than functional.

* Ensured that bookmarks are fetched and ordered by Created date.

* Fixed a bug where bookmarks were coming back in the correct order, but due to filenames, would not sort correctly.

* Default installs to Debug log level given errors users have and Debug not being too noisy

* Added jumpbar to bookmarks page

* Now added jumpbar to bookmarks

* Refactored some code into pipes and added some debug messaging for prefetcher

* Try loading next and prev chapter's first/last page to cache so it renders faster

* Updated GetImage to do a bound check on max page.

Fixed a critical bug in how manga reader updates image elements src to prefetch/load pages. I was not creating a new reference which broke Angular's ability to update DOM on changes.

* Refactored the image setting code to use a single method which tries to use a cached image always.

* Refactored code to use getPage which favors cache and simplifies image creation code

* Started the work to split the canvas renderer into it's own component

* Refactored a lot of common methods into a service for the reader to support the upcoming renderer split

* Moved components to nested folder. Refactored more code to streamline image sending to child renderer.

Added notes across the code to help streamline flow of data and who owns what.

* Swapped out SQLite for Memory, but the one from hangfire. Added DisableConcurrentExecution on ProcessChange to avoid duplication when multiple threads execute at once.

* Basic split right to left is working with canvas renderer

* Left to right and right to left now work

* Fixed a bug where pagesplitoption wasn't being updated when modifying menu

* Canvas rendering still has a bug with switching between right to left -> left to right on the re-render, it will choose a bad state. All else works fine with it.

* Updated canvas renderer to implement the ImageRenderer interface

* Canvas renderer is done

* Setup single renderer. Need to figure out how to share CSS between renderers and also share some global stuff, like image height.

* Refactored code so that image-container is within the renderers themselves. Still broken in scaling, but working towards a solution.

* Added double click to shortcut menu

* Moved image containers within the renderers

* Pushing up for Robbie

* nothing new

* Move common css to a single scss file

* More css consolidation

* Fixed a npe in isWideImage

* Refactored page updates to renderers to include max pages. Rewrote most of renderer into observables.

* Moved bookmark for second page to double renderer

* Started hooking in double renderer renderPage()

* Fixed height scaling, but now canvas renderer is broken again

* Fixed a bug with canvas renderer not moving to next page. Streamlined the code for getting page amounts from the dfferent renderers

* Added double click to bookmark for canvas

* Stashing the code and taking a break

* Nothing much, buffer is still broken

* Got double renderer to render at least one page

* Double renderer now has access to 5 images at any time, so it can make appropriate decisions on when to render double pages.

* Fixed up double rendererer moving backward page calc

* Forward logic seems to be working

* Cleaned up dead code after testing

* Moved a few loggers in folder watching to trace

* Everything seems to work fine, time to do double manga renderer

* Moved some css around and added the reverse double component

* Only execute renderer's pipes when in the correct mode

* Still working on double renderer

* Fixed scaling issues on double

* Updating double logic

- Fixed: Fixed an issue where a second page would render when current page was wide.

* Hooked up double renderer

* Made changes but not sure if im making progress

* double manga fixes

* Claned some of robbies code

* Fixing last page bug

* Library Settings Modal + New Library Settings (#1660)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed want to read button on series detail not performing the correct action

* Started the library settings. Added ability to update a cover image for a library.

Updated backup db to also copy reading list (and now library) cover images.

* Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library.

* Fixed a missing update event in backend when updating a library.

* Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid.

* Trim library names before you check anything

* General code cleanup

* Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries.

Refactored some code to streamline perf in some flows.

* Removed old components replaced with new modal

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* UX Alignment and bugfixes (#1663)

* Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter.

Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop.

* Removed a bug marker that I just fixed

* When generating library covers, make them much smaller as they are only ever icons.

* Fixed library settings not showing the correct image.

* Fixed a bug where duplicate collection tags could be created.

Fixed a bug where collection tag normalized title was being set to uppercase.

Redesigned the edit collection tag modal to align with new library settings and provide inline name checks.

* Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names.

Don't show Continue point on series detail if the whole series is read.

* Added some more unit tests around continue point

* Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab.

* Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting.

Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build).

* Test GA

* Reverted GA and instead do it in the build step. This will just force developers to commit it in.

* GA please work

* Removed redundant steps from test since build already does it.

* Try another GA

* Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes.

* Fixed env variable

* Okay not possible to do secrets in if statement

* Fixed the build step to output the openapi.json where it's expected.

* Fixed GA (#1664)

* Bump versions by dotnet-bump-version.

* Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666)

* Fixed typeahead and updated manga reader to new layout structure

* Fixed book reader fonts lookups

* Fixed up some build issues

* Fixed  a bad import of css image

* Some cleanup and rewrote how we log out data.

* Renderer can be null on first load when performing some work.

* Library Settings Modal + New Library Settings (#1660)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed want to read button on series detail not performing the correct action

* Started the library settings. Added ability to update a cover image for a library.

Updated backup db to also copy reading list (and now library) cover images.

* Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library.

* Fixed a missing update event in backend when updating a library.

* Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid.

* Trim library names before you check anything

* General code cleanup

* Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries.

Refactored some code to streamline perf in some flows.

* Removed old components replaced with new modal

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* UX Alignment and bugfixes (#1663)

* Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter.

Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop.

* Removed a bug marker that I just fixed

* When generating library covers, make them much smaller as they are only ever icons.

* Fixed library settings not showing the correct image.

* Fixed a bug where duplicate collection tags could be created.

Fixed a bug where collection tag normalized title was being set to uppercase.

Redesigned the edit collection tag modal to align with new library settings and provide inline name checks.

* Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names.

Don't show Continue point on series detail if the whole series is read.

* Added some more unit tests around continue point

* Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab.

* Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting.

Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build).

* Test GA

* Reverted GA and instead do it in the build step. This will just force developers to commit it in.

* GA please work

* Removed redundant steps from test since build already does it.

* Try another GA

* Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes.

* Fixed env variable

* Okay not possible to do secrets in if statement

* Fixed the build step to output the openapi.json where it's expected.

* Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666)

* Post merge cleanup

* Again moving the file

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* Basic Stats (#1673)

* Refactored ResponseCache profiles into consts

* Refactored code to use an extension method for getting user library ids.

* Started server statistics, added a charting library, and added a table sort column (not finished)

* Refactored code and have a fully working example of sortable headers. Still doesn't work with default sorting state, will work on that later.

* Implemented file size, but it's too expensive, so commented out.

* Added a migration to provide extension and length/size information in the DB to allow for faster stat apis.

* Added the ability to force a library scan from library settings.

* Refactored some apis to provide more of a file breakdown rather than just file size.

* Working on visualization of file breakdown

* Fixed the file breakdown visual

* Fixed up 2 visualizations

* Added back an api for member names, started work on top reads

* Hooked up the other library types and username/days.

* Preparing to remove top reads and refactor into Top users

* Added LibraryId to AppUserProgress to help with complex lookups.

* Added the new libraryId hook into some stats methods

* Updated api methods to use libraryId for progress

* More places where LibraryId is needed

* Added some high level server stats

* Got a ton done on server stats

* Updated default theme (dark) to be the default root variables. This will allow user themes to override just what they want, rather than maintain their own css variables.

* Implemented a monster query for top users by reading time. It's very slow and can be cleaned up likely.

* Hooked up top reads. Code needs a big refactor. Handing off for Robbie treatment and I'll switch to User stats.

* Implemented last 5 recently read series (broken) and added some basic css

* Fixed recently read query

* Cleanup the css a bit, Robbie we need you

* More css love

* Cleaned up DTOs that aren't needed anymore

* Fixed top readers query

* When calculating top readers, don't include read events where nothing is read (0 pages)

* Hooked up the date into GetTopUsers

* Hooked top readers up with days and refactored and cleaned up componets not used

* Fixed up query

* Started on a day by day breakdown, but going to take a break from stats.

* Added a temp task to run some migration manually for stats to work

* Ensure OPDS-PS uses new libraryId for progress reporting

* Fixed a code smell

* Adding some styling

* adding more styles

* Removed some debug stuff from user stats

* Bump qs from 6.5.2 to 6.5.3 in /UI/Web

Bumps [qs](https://github.com/ljharb/qs) from 6.5.2 to 6.5.3.
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.2...v6.5.3)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Tweaked some code for bad data cases

* Refactored a chapter lookup to remove un-needed Volume join in 5 places across the code.

* API push

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* Hooked up the API layer to be able to extract images from PDF again for Tachiyomi explicitly (#1686)

* Bump versions by dotnet-bump-version.

* OPDS Enhancements (#1687)

* Bump express from 4.17.2 to 4.18.2 in /UI/Web

Bumps [express](https://github.com/expressjs/express) from 4.17.2 to 4.18.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.17.2...4.18.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump decode-uri-component from 0.2.0 to 0.2.2 in /UI/Web

Bumps [decode-uri-component](https://github.com/SamVerschueren/decode-uri-component) from 0.2.0 to 0.2.2.
- [Release notes](https://github.com/SamVerschueren/decode-uri-component/releases)
- [Commits](https://github.com/SamVerschueren/decode-uri-component/compare/v0.2.0...v0.2.2)

---
updated-dependencies:
- dependency-name: decode-uri-component
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Bump qs and express in /UI/Web

Bumps [qs](https://github.com/ljharb/qs) and [express](https://github.com/expressjs/express). These dependencies needed to be updated together.

Updates `qs` from 6.5.3 to 6.11.0
- [Release notes](https://github.com/ljharb/qs/releases)
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.5.3...v6.11.0)

Updates `express` from 4.17.2 to 4.18.2
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.17.2...4.18.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-type: indirect
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Added genre and authors to Series level, added summary to volume and chapter level.

Force order on reading list title as Chunky enforces their own sort order and doesn't respect the spec.

* Moved all the reading list formatting logic to the backend. This allows us to re-use the UI logic for OPDS streams.

* Fixed a broken unit test

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* Epub Table of Generation fixes for Sigil (#1689)

* Fixed generating table of contents where key lookup could fail with how Sigil packs the epubs.

* Tweaked Kavita's fallback ToC generation (when one doesn't exist in the epub) to also use CoalesceKey.

* Code smells

* Bump versions by dotnet-bump-version.

* File Dimension API (#1690)

* Implemented an api for getting file dimensions for a given chapter. This is for CDisplayEx integration. This might be usable in Double Renderer.

* Added the cached filename for new API

* Bump versions by dotnet-bump-version.

* Send Non books to your Devices (#1691)

* Only restrict non-epub/pdf for Kindle devices on Send To.

* Removed restriction to email non-epub/pdfs to devices.

* Bump versions by dotnet-bump-version.

* Misc UI Tweaks (#1692)

* Added a timeAgo pipe which shows live updates for a few areas.

* Fixed some wording on stats page. Changed Total People count to just work on distinct names and not count multiple for different roles.

* Tweaked the compact number so it only shows one decimal

* Fixed a bug

* Bump versions by dotnet-bump-version.

* Reader Refactor Part 2 (#1694)

* Updated swiper and some packages for reported security issues

* Fixed reading lists promotion not working

* Refactor RenameFileForCopy to use iterative recursion, rather than functional.

* Ensured that bookmarks are fetched and ordered by Created date.

* Fixed a bug where bookmarks were coming back in the correct order, but due to filenames, would not sort correctly.

* Default installs to Debug log level given errors users have and Debug not being too noisy

* Added jumpbar to bookmarks page

* Now added jumpbar to bookmarks

* Refactored some code into pipes and added some debug messaging for prefetcher

* Try loading next and prev chapter's first/last page to cache so it renders faster

* Updated GetImage to do a bound check on max page.

Fixed a critical bug in how manga reader updates image elements src to prefetch/load pages. I was not creating a new reference which broke Angular's ability to update DOM on changes.

* Refactored the image setting code to use a single method which tries to use a cached image always.

* Refactored code to use getPage which favors cache and simplifies image creation code

* Started the work to split the canvas renderer into it's own component

* Refactored a lot of common methods into a service for the reader to support the upcoming renderer split

* Moved components to nested folder. Refactored more code to streamline image sending to child renderer.

Added notes across the code to help streamline flow of data and who owns what.

* Swapped out SQLite for Memory, but the one from hangfire. Added DisableConcurrentExecution on ProcessChange to avoid duplication when multiple threads execute at once.

* Basic split right to left is working with canvas renderer

* Left to right and right to left now work

* Fixed a bug where pagesplitoption wasn't being updated when modifying menu

* Canvas rendering still has a bug with switching between right to left -> left to right on the re-render, it will choose a bad state. All else works fine with it.

* Updated canvas renderer to implement the ImageRenderer interface

* Canvas renderer is done

* Setup single renderer. Need to figure out how to share CSS between renderers and also share some global stuff, like image height.

* Refactored code so that image-container is within the renderers themselves. Still broken in scaling, but working towards a solution.

* Added double click to shortcut menu

* Moved image containers within the renderers

* Pushing up for Robbie

* nothing new

* Move common css to a single scss file

* More css consolidation

* Fixed a npe in isWideImage

* Refactored page updates to renderers to include max pages. Rewrote most of renderer into observables.

* Moved bookmark for second page to double renderer

* Started hooking in double renderer renderPage()

* Fixed height scaling, but now canvas renderer is broken again

* Fixed a bug with canvas renderer not moving to next page. Streamlined the code for getting page amounts from the dfferent renderers

* Added double click to bookmark for canvas

* Stashing the code and taking a break

* Nothing much, buffer is still broken

* Got double renderer to render at least one page

* Double renderer now has access to 5 images at any time, so it can make appropriate decisions on when to render double pages.

* Fixed up double rendererer moving backward page calc

* Forward logic seems to be working

* Cleaned up dead code after testing

* Moved a few loggers in folder watching to trace

* Everything seems to work fine, time to do double manga renderer

* Moved some css around and added the reverse double component

* Only execute renderer's pipes when in the correct mode

* Still working on double renderer

* Fixed scaling issues on double

* Updating double logic

- Fixed: Fixed an issue where a second page would render when current page was wide.

* Hooked up double renderer

* Made changes but not sure if im making progress

* double manga fixes

* Claned some of robbies code

* Fixing last page bug

* Library Settings Modal + New Library Settings (#1660)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed want to read button on series detail not performing the correct action

* Started the library settings. Added ability to update a cover image for a library.

Updated backup db to also copy reading list (and now library) cover images.

* Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library.

* Fixed a missing update event in backend when updating a library.

* Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid.

* Trim library names before you check anything

* General code cleanup

* Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries.

Refactored some code to streamline perf in some flows.

* Removed old components replaced with new modal

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* UX Alignment and bugfixes (#1663)

* Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter.

Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop.

* Removed a bug marker that I just fixed

* When generating library covers, make them much smaller as they are only ever icons.

* Fixed library settings not showing the correct image.

* Fixed a bug where duplicate collection tags could be created.

Fixed a bug where collection tag normalized title was being set to uppercase.

Redesigned the edit collection tag modal to align with new library settings and provide inline name checks.

* Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names.

Don't show Continue point on series detail if the whole series is read.

* Added some more unit tests around continue point

* Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab.

* Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting.

Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build).

* Test GA

* Reverted GA and instead do it in the build step. This will just force developers to commit it in.

* GA please work

* Removed redundant steps from test since build already does it.

* Try another GA

* Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes.

* Fixed env variable

* Okay not possible to do secrets in if statement

* Fixed the build step to output the openapi.json where it's expected.

* Fixed GA (#1664)

* Bump versions by dotnet-bump-version.

* Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666)

* Fixed typeahead and updated manga reader to new layout structure

* Fixed book reader fonts lookups

* Fixed up some build issues

* Fixed  a bad import of css image

* Some cleanup and rewrote how we log out data.

* Renderer can be null on first load when performing some work.

* Library Settings Modal + New Library Settings (#1660)

* Bump loader-utils from 2.0.3 to 2.0.4 in /UI/Web

Bumps [loader-utils](https://github.com/webpack/loader-utils) from 2.0.3 to 2.0.4.
- [Release notes](https://github.com/webpack/loader-utils/releases)
- [Changelog](https://github.com/webpack/loader-utils/blob/v2.0.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/loader-utils/compare/v2.0.3...v2.0.4)

---
updated-dependencies:
- dependency-name: loader-utils
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixed want to read button on series detail not performing the correct action

* Started the library settings. Added ability to update a cover image for a library.

Updated backup db to also copy reading list (and now library) cover images.

* Integrated Edit Library into new settings (not tested) and hooked up a wizard-like flow for new library.

* Fixed a missing update event in backend when updating a library.

* Disable Save when form invalid. Do inline validation on Library name when user types to ensure the name is valid.

* Trim library names before you check anything

* General code cleanup

* Implemented advanced settings for library (include in dashboard, search, recommended) and ability to turn off folder watching for individual libraries.

Refactored some code to streamline perf in some flows.

* Removed old components replaced with new modal

* Code smells

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* UX Alignment and bugfixes (#1663)

* Refactored the design of reading list page to follow more in line with list view. Added release date on the reading list items, if it's set in underlying chapter.

Fixed a bug where reordering the list items could sometimes not update correctly with drag and drop.

* Removed a bug marker that I just fixed

* When generating library covers, make them much smaller as they are only ever icons.

* Fixed library settings not showing the correct image.

* Fixed a bug where duplicate collection tags could be created.

Fixed a bug where collection tag normalized title was being set to uppercase.

Redesigned the edit collection tag modal to align with new library settings and provide inline name checks.

* Updated edit reading list modal to align with new library settings modal pattern. Refactored the backend to ensure it flows correctly without allowing duplicate names.

Don't show Continue point on series detail if the whole series is read.

* Added some more unit tests around continue point

* Fixed a bug on series detail when bulk selecting between volume and chapters, the code which determines which chapters are selected didn't take into account mixed layout for Storyline tab.

* Refactored to generate an OpenAPI spec at root of Kavita. This will be loaded by a new API site for easy hosting.

Deprecated EnableSwaggerUi preference as after validation new system works, this will be removed and instances can use our hosting to hit their server (or run a debug build).

* Test GA

* Reverted GA and instead do it in the build step. This will just force developers to commit it in.

* GA please work

* Removed redundant steps from test since build already does it.

* Try another GA

* Moved all test actions into initial build step, which should drastically cut down on time. Only run sonar if the secret is present (so not for forks). Updated build requirements for develop and stable docker pushes.

* Fixed env variable

* Okay not possible to do secrets in if statement

* Fixed the build step to output the openapi.json where it's expected.

* Applied new _components layout structure to Kavita. All except manga as there is an open PR that drastically changes that module. (#1666)

* Post merge cleanup

* Again moving the file

* Fixed an issue with switching to double renderer and the image not loading for cover image.

* Fixed double manga last page repeating twice

* Added ability to quickly save a few settings to user preferences from manga reader

* Fixed up some success messaging

* Single image and canvas could stack, last page on double wouldn't render.

* Stashing code, want to work on something else

* Suppress a concurrency issue when opening a fresh chapter to read.

* Refactored a function into a pipe

* Took care of one TODO

* Tightened up the logic around single renderer handling fit to screen images.

* Added some code to see how long api takes on average.

* First pass integration of page dimensions into single renderer and base code

* Canvas renderer pass for new page dimensions

* On time left, don't use the word left again

* Moved the page dimension code into manga service to make it seemless

* Hooked in a replacement for image based isWide

* Canvas renderer is working again

* Double renderer now follows how Komga does it to keep it simple.

* Double renderer is working really well so far.

* don't use nbsp

* Added response caching to file-dimensions and chapter info api

* Allow chapter info to send back file dimensions optionally

* Fixed an issue with dimensions api locking files on Windows

* Refactored all code to use isWidePage

* More fixes and cleanup

* More double reverse logic

* Recently Read stats page will allow you to click the items.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* More Reader Fixes (#1696)

* Fixed resizing or layout changes causing page change on double reader

* Implemented the debug log pattern on double renderers. Fixed a case when navigation backwards and showing only one page. Updated so go to page or slider update will handle selecting the right page number for pair display.

* All Spread cases for double working

* Cleanup dead code

* Ensure we can jump to last page

* Bump versions by dotnet-bump-version.

* Reading History (#1699)

* Added new stat graph for pages read over time for all users.

* Switched to reading events rather than pages read to get a better scale

* Changed query to use Created date as LastModified wont work since I just did a migration on all rows.

* Small cleanup on graph

* Read by day completed and ready for user stats page.

* Changed the initial stat report to be in 1 day, to avoid people trying and ditching the software from muddying up the stats.

* Cleaned up stats page such that stats around series show their image and tweaked some layout and wordings

* Fixed recently read order

* Put read history on user profile

* Final cleanup, Robbie needs to do a CSS pass before release.

* Bump versions by dotnet-bump-version.

* Performance Improvements and Some Polish (#1702)

* Auto scale reading timeline

* Added benchmarks for SharpImage and NetVips. When an epub has a malformed page, catch the error and present it better to the user.

* Added a hint for an upcoming feature

* Slightly sped up word count for epubs

* Added one more test to reflect actual code.

* Some light cleanup

* Use compact number for stat lists

* Fixed brightness being broken on manga reader

* Replaced CoverToWebP SharpImage version with NetVips which is MUCH lighter on memory and CPU.

* Added last modified on the progress dto for CdDisplayEx.

* Code cleanup

* Forgot one cleanup

* Bump versions by dotnet-bump-version.

* Holiday Changes (#1706)

* Fixed a bug on bookmark mode not finding correct image for prefetcher.

* Fixed up the edit series relationship modal on tablet viewports.

* On double page mode, only bookmark 1 page if only 1 pages is renderered on screen.

* Added percentage read of a given library and average hours read per week to user stats.

* Fixed a bug in the reader with paging in bookmark mode

* Added a "This Week" option to top readers history

* Added date ranges for reading time. Added dates that don't have anything, but might remove.

* On phone, when applying a metadata filter, when clicking apply, collapse the filter automatically.

* Disable jump bar and the resuming from last spot when a custom sort is applied.

* Ensure all Regex.Replace or Matches have timeouts set

* Bump versions by dotnet-bump-version.

* First PR of the new year (#1717)

* Fixed a bug on bookmark mode not finding correct image for prefetcher.

* Fixed up the edit series relationship modal on tablet viewports.

* On double page mode, only bookmark 1 page if only 1 pages is renderered on screen.

* Added percentage read of a given library and average hours read per week to user stats.

* Fixed a bug in the reader with paging in bookmark mode

* Added a "This Week" option to top readers history

* Added date ranges for reading time. Added dates that don't have anything, but might remove.

* On phone, when applying a metadata filter, when clicking apply, collapse the filter automatically.

* Disable jump bar and the resuming from last spot when a custom sort is applied.

* Ensure all Regex.Replace or Matches have timeouts set

* Fixed a long standing bug where fit to height on tablets wouldn't center the image

* Streamlined url parsing to be more reliable

* Reduced an additional db query in chapter info.

* Added a missing task to convert covers to webP and added messaging to help the user understand to run it after modifying the setting.

* Changed OPDS to be enabled by default for new installs. This should reduce issues with users being confused about it before it's enabled.

* When there are multiple files for a chapter, show a count card on the series detail to help user understand duplicates exist. Made the unread badge smaller to avoid collision.

* Added Word Count to user stats and wired up average reading per week.

* Fixed word count failing on some epubs

* Removed some debug code

* Don't give more information than is necessary about file paths for page dimensions.

* Fixed a bug where pagination area would be too small when the book's content was less that height on default mode.

* Updated Default layout mode to Scroll for books.

* Added bytes in the UI and at an API layer for CDisplayEx

* Don't log health checks to logs at all.

* Changed Word Count to Length to match the way pages work

* Made reading time more clear when min hours is 0

* Apply more aggressive coalescing when remapping bad metadata keys for epubs.

* Changed the amount of padding between icon and text for side nav item.

* Fixed a NPE on book reader (harmless)

* Fixed an ordering issue where Volume 1 was a single file but also tagged as Chapter 1 and Volume 2 was Chapter 0. Thus Volume 2 was being selected for continue point when Volume 1 should have been.

* When clicking on an activity stream header from dashboard, show the title on the resulting page.

* Removed a property that can't be animated

* Fixed a typeahead typescript issue

* Added Size into Series Info and Added some tooltip and spacing changes to better explain some fields.

* Added size for volume drawers and cleaned up some date edge case handling

* Fixed an annoying bug where when on mobile opening a view with a metadata filter, Kavita would open the filter automatically.

* Bump versions by dotnet-bump-version.

* Quick fix for Double Renderer (#1719)

* Disable emulate comic book when on single page reader

* Fixed a regression where double page renderer wouldn't layout the images correctly

* Bump versions by dotnet-bump-version.

* Feature/stats finishoff (#1720)

* Added ability to click on genres, tags, and people to view all items in a modal.

* Made it so we can click and open a filtered search from generic list

* Fixed broken epub pagination area due to a typo in a query selector

* Added day breakdown, wrapping up stats

* Bump versions by dotnet-bump-version.

* Docker nonroot (#1650)

* Added PUID, PGID and KAVITAUSER variable support in entrypoint.sh

* Update the setting of ownership to avoid changing library files

* Default to run as root, using user kavita if alternate UID/GID are provided

* Only chown config folder and only if needed

* Revert chmod on Kavita

Co-authored-by: Muggz <mug@passw0rd.org>

* Bump versions by dotnet-bump-version.

* Manga Reader Work (#1729)

* Instead of augmenting prefetcher to move across chapter bounds, let's try to instead just load 5 images (which the browser will cache) from next/prev so when it loads, it's much faster.

* Trialing loading next/prev chapters 5 pages to have better next page loading experience.

* Tweaked GetChapterInfo API to actually apply conditional includeDimensions parameter.

* added a basic language file for upcoming work

* Moved the bottom menu up a bit for iOS devices with handlebars.

* Fixed fit to width on phones still having a horizontal scrollbar

* Fixed a bug where there is extra space under the image when fit to width and on a phone due to pagination going to far.

* Changed which variable we use for right pagination calculation

* Fixing fit to height

- Fixing height calc to account for horizontal scroll bar height.

* Added a comment for the height scrollbar fix

* Adding screenfull package

# Added:
- Added screenfull package to handle cross-platform browser fullscreen code

# Removed:
- Removed custom fullscreen code

* Fixed a bug where switching from webtoon reader to other layout modes wouldn't render anything. Webtoon continuous scroll down is now broken.

* Fixed it back to how it was and all is good. Need to call detectChanges explicitly.

* Removed an additional undeeded save progress call on loadPage

* Laid out the test case to move the page snapping to the backend with full unit tests. Current code is broken just like UI layer.

* Refactored the snap points into the backend and ensure that it works correctly.

* Fixed a broken unit test

* Filter out spammy hubs/messages calls in the logs

* Swallow all noisy messages that are from RequestLoggingMiddleware when the log level is on Information or above.

* Added a common loading component to the app. Have yet to refactor all screens to use this.

* Bump json5 from 2.2.0 to 2.2.3 in /UI/Web

Bumps [json5](https://github.com/json5/json5) from 2.2.0 to 2.2.3.
- [Release notes](https://github.com/json5/json5/releases)
- [Changelog](https://github.com/json5/json5/blob/main/CHANGELOG.md)
- [Commits](https://github.com/json5/json5/compare/v2.2.0...v2.2.3)

---
updated-dependencies:
- dependency-name: json5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

* Alrigned all the loading messages and styles throughout the app

* Webtoon reader will use max width of all images to ensure images align well.

* On Original scaling mode, users can use the keyboard to scroll around the images without pagination kicking off.

* Removed console logs

* Fixed a public vs private issue

* Fixed an issue around some cached files getting locked due to NetVips holding them during file size calculations.

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Bump versions by dotnet-bump-version.

* [Manga Reader] Swipe Support (#1735)

* Fixed a loading indicator that is always on

* Started to add swipe directive

* Implemented the ability to swipe to navigate pages in manga reader.

* Swipe to paginate seems to be working reliably

* Removed a bunch of junk from csproj and added a debug menu for testing on phone to smooth out experience.

* Fixed a bug where reading list detail wouldn't render the set image of the reading list.

* Added some instructions and code to allow connecting to dev instance easier.

* Fixed up paging with keyboard where to ensure that when we hit the end of the scroll, we don't go to the next page instantly, but rather make the user press the key once more.

* Fixed reading list image not properly renderering on reading list detail page.

* Solved the swiping bug, need to play with threshold again.

* Swipe is now working. Need to decide if I'm going to support reversing the direction with reading direction.

* Hooked up swipe with reading direction code

* Cleaned up some direction code to align to a new enum

* Feature complete

* Bump versions by dotnet-bump-version.

* Fix pagination in Manga reader from last PR (#1736)

* Added a new Double (No Cover) rendering mode which always has first 2 pages together unless wide.

* Removed layout mode for build

* Bump versions by dotnet-bump-version.

* Better Themes, Stats, and bugfixes (#1740)

* Fixed a bug where when clicking on a series rating for first time, the rating wasn't populating in the modal.

* Fixed a bug on Scroll mode with immersive mode, the bottom bar could clip with the book body.

* Cleanup some uses of var

* Refactored text as json into a type so I don't have to copy/paste everywhere

* Theme styles now override the defaults and theme owners no longer need to maintain all the variables themselves.

Themes can now override the color of the header on mobile devices via --theme-color and Kavita will now update both theme color as well as color scheme.

* Fixed a bug where last active on user stats wasn't for the particular user.

* Added a more accurate word count calculation and the ability to see the word counts year over year.

* Added a new table for long term statistics, like number of files over the years. No views are present for this data, I will add them later.

* Bump versions by dotnet-bump-version.

* Swipe Issues (#1745)

* Updated theme support to be able to customize the tile color dynamically from a theme via --tile-color. In addition, --theme-color will update apple-mobile-web-app-status-bar-style as well as the non-apple variants

* Removed --manga-reader-bg-color as it wasn't used anywhere. Fixed double pagination on swipe.

* Cleaned up some dead threshold code for swipe.

* Started refactoring tests to use an abstract test class. Stopping because I should do on the .net 7 branch to avoid large merge conflicts. Tests need to be re-designed so they can run in parallel.

* Fixed a bug in reading lists where when deleting an item, order could be miscalculated.

* Started adding new information for stat service. Refactored time spent reading to be more accurate by taking average time against how much of the chapter the user has read.

* Hooked up total time reading at server stat level. Don't show fancy graphs on mobile.

* Added new stats for v0.7

* Added a test for Clearing want to read

* Fixed a few tests that weren't resetting state between runs

* Fixed some broken unit tests

* Ensure all Series queries sort by a case invariant string.

* Added more aggressive caching of images. This will result in a min delay on pages after a cover is changed.

* Fixed a bug where if during new word count calculation, new word count is zero, restoring the old count wasn't working.

* Cleaned up some of the code for getting time estimates

* Fixed a bug where triggering swipe right wasn't working when there was no scroll

* Delete the temp folder for creating a download after a full zip is created.

* Bump versions by dotnet-bump-version.

* Stat hotfix (#1748)

* Fixed a bug where a divide by 0 could occur

* Email change now requires a password

* Bump versions by dotnet-bump-version.

* Holiday Bugfixes (#1762)

* Don't show "not much going on" when we are actively downloading

* Swipe to paginate is now behind a flag in the user preferences.

* Added a new server setting for host name, if the server sits behind a reverse proxy. If this is set, email link generation will use it and will not perform any checks on accessibility (thus email will always send)

* Refactored the code that checks if the server is accessible to check if host name is set, and thus return rue if so.

* Added back the system drawing library for markdown parsing.

* Fixed a validation error

* Fixed a bug where folder watching could get re-triggered when it was disabled at a server level.

* Made the manga reader loader absolute positioned for better visibility

* Indentation

* Bump versions by dotnet-bump-version.

* Angular 15 (#1764)

* Updated ngx-virtual-scroller

* Removed the karma test config as it's breaking migration

* Reverted to pre angular 15

* Upgraded packages and reverted target to ES6 for older devices

* It's broken. Need to also find the safari version for old Ipads

* Fixes some code in default pipe and many updates to packages. Removed support for old iOS versions as it restricted Kavita from using newer features. Build still broken.

* More progress in getting build working on Angular 15. Removed polyfills.ts for new angular config

* Remove all.css for icons and use scss instead

* Removed stuff that isn't needed

* Migrated extended linting to eslint, ran on project and updated issues. Removed a duplicate component that did nothing. Fixed a few places where lifecycle hooks werent being called as interface wasn't implemented.

* App builds correctly. Source maps are still needed.

* Fixed source maps and removed more testing stuff. I will re-add later in another release when I figure out how to properly tackle dependencies on backend.

* Reverted back to old source map definition

* Bump versions by dotnet-bump-version.

* Angular 15 (#1765)

* Refactored some code in BookService to make the code easier to understand

* More lint fixes

* Use npm ci for installs in pipeline

* Fixed build system again by deleting nodejs. New build system uses package-lcok going forward.

* Bump versions by dotnet-bump-version.

* [skip ci] Misc stuff (#1766)

* Refactored some code in BookService to make the code easier to understand

* More lint fixes

* Use npm ci for installs in pipeline

* Fixed build system again by deleting nodejs. New build system uses package-lcok going forward.

* Added a test case for Reading Time Estimation calculations

* Some cleanup

* Added even more testing to try and get scare's issue captured.

* Automatic Collection Creation (#1768)

* Made the unread badges slightly smaller and rounded on top right.

* A bit more tweaks on the not read badges. Looking really nice now.

* In order to start the work on managing collections from ScanLoop, I needed to refactor collection apis into the service layer and add unit tests.

Removed ToUpper Normalization for new tags.

* Hooked up ability to auto generate collections from SeriesGroup metadata tag.

* Bump versions by dotnet-bump-version.

* Auto Collection Bugfixes (#1769)

* SeriesGroup tag can now have comma separated value to allow a series to be a part of multiple collections.

* Added a missing unit test

* Refactored how collection tags are created to work in the scan loop reliably.

* Added a unit test for RemoveTagsWithoutSeries

* Fixed a bug in reading list title generation to avoid Volume 0 if the underlying file had a title set. Fixed a misconfigured unit test.

* Bump versions by dotnet-bump-version.

* Scanner Performance Improvements (#1774)

* Refactored the Genre code to be faster and used a dictonary to avoid some lookups. May fix the rare foreign constraint issue.

* Refactored tag to the same implementation as Genre. Ensure when grabbing tags from ComicInfo, we normalize and throw out duplicates.

* Removed an internal "external" field that was planned for Genres and Tags, but now with new plugin architecture, not needed.

* Bump versions by dotnet-bump-version.

* Spelling, grammar, and related consistency improvements (#1756)

* Spelling, grammar, and word structure improvements

* Email service text reworded to account for the Host Name feature

* Bump versions by dotnet-bump-version.

* Stat Polish (#1775)

* SeriesGroup tag can now have comma separated value to allow a series to be a part of multiple collections.

* Added a missing unit test

* Refactored how collection tags are created to work in the scan loop reliably.

* Added a unit test for RemoveTagsWithoutSeries

* Fixed a bug in reading list title generation to avoid Volume 0 if the underlying file had a title set. Fixed a misconfigured unit test.

* On User stats page, don't show the user selector on reading history, despite if youre an admin. Cleaned up how we show days with 0 reading events to be more clear.

* Refactored the name of a component to reflect what it does

* Removed plugin not using

* Fix an issue where coalescing a key in epub might have multiple html files ending with the key. In this case, let's take the first.

* Added PikaPods to the Readme

* Tried to fix layout shift for charts, but need Robbie's help

* Chart styling

# Added:
- Added: Added styling to force charts into their respective containers.

# Removed:
- Removed: Removed code blocking charts from being visible on mobile.

* Merge conflict

---------

Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>

* Bump versions by dotnet-bump-version.

* UTC Dates + CDisplayEx API Enhancements (#1781)

* Introduced a new claim on the Token to get UserId as well as Username, thus allowing for many places of reduced DB calls. All users will need to reauthenticate.

Introduced UTC Dates throughout the application, they are not exposed in all DTOs, that will come later when we fully switch over. For now, Utc dates will be updated along side timezone specific dates.

Refactored get-progress/progress api to be 50% faster by reducing how much data is loaded from the query.

* Speed up the following apis:
collection/search, download/bookmarks, reader/bookmark-info, recommended/quick-reads, recommended/quick-catchup-reads, recommended/highly-rated, recommended/more-in, recommended/rediscover, want-to-read/

* Added a migration to sync all dates with their new UTC counterpart.

* Added LastReadingProgressUtc onto ChapterDto for some browsing apis, but not all.

Added LastReadingProgressUtc to reading list items.

Refactored the migration to run raw SQL which is much faster.

* Added LastReadingProgressUtc onto ChapterDto for some browsing apis, but not all.

Added LastReadingProgressUtc to reading list items.

Refactored the migration to run raw SQL which is much faster.

* Fixed the unit tests

* Fixed an issue with auto mapper which was causing progress page number to not get sent to UI

* series/volume has chapter last reading progress

* Added filesize and library name on reading list item dto for CDisplayEx.

* Some minor code cleanup

* Forgot to fill a field

* Bump versions by dotnet-bump-version.

* Reading List Fixes (#1784)

* Add ability to save readinglist comicinfo fields in Chapter.

* Added the appropriate fields and migration for Reading List generation.

* Started the reading list code

* Started building out the CBL import code with some initial unit tests.

* Fixed first unit test

* Started refactoring control code into services and writing unit tests for ReadingLists. Found a logic issue around reading list title between create/update. Will be corrected in this branch with unit tests.

* Can't figure out how to mock UserManager, so had to uncomment a few tests.

* Tooltip for total pages read shows the full number

* Tweaked the math a bit for average reading per week.

* Fixed up the reading list unit tests. Fixed an issue where when inserting chapters into a blank reading list, the initial reading list item would have an order of 1 instead of 0.

* Cleaned up the code to allow the reading list code to be localized easily and fixed up a bug in last PR.

* Fixed a sorting issue on reading activity

* Tweaked the code around reading list actionables not showing due to some weird filter.

* Fixed edit library settings not opening on library detail page

* Fixed a bug where reading activity dates would be out of order due to a bug in how charts works. A temp hack has been added.

* Disable promotion in edit reading list modal since non-admins can (and should have) been able to use it.

* Fixed a bug where non-admins couldn't update their OWN reading lists. Made uploading a cover image for readinglists now check against the user's reading list access to allow non-admin's to set images.

* Fixed an issue introduced earlier in PR where adding chapters to reading list could cause order to get skewed.

* Fixed another regression from earlier commit

* Hooked in Import CBL flow. No functionality yet.

* Code is a mess. Shifting how the whole import process is going to be done. Commiting so I can pivot drastically.

* Very rough code for first step is done.

* Ui has started, I've run out of steam for this feature.

* Cleaned up the UI code a bit to make the step tracker nature easier without a dedicated component.

* Much flow implementation and tweaking to how validation checks and what is sent back.

* Removed import via cbl code as it's not done. Pushing to next release.

* Bump versions by dotnet-bump-version.

* Allow changing listening ip addresses (#1713)

* Allow changing listening ip address

* Use Json serialize for appsettings.config saving

* BOM

* IP Address validation

* ip address reset

* ValidIpAddress regex

* Bump versions by dotnet-bump-version.

* Release Testing Time (#1785)

* Fixed a bug with getting continue point where there was a single volume unread and a later volume with chapters inside it, the chapters were being picked.

* Fixed a bug where resuming from jump key wasn't working (develop)

* Cleaned up the spacing

* Bump versions by dotnet-bump-version.

* Release Testing Bugs (#1790)

* Stop showing loading indicator when no next/prev chapter

* Fixed a bug where manage collections wasn't named correctly in UI.

* Slight tweaks on email flow

* Bump versions by dotnet-bump-version.

* Release Testing Part 2 (#1794)

* Stop showing loading indicator when no next/prev chapter

* Fixed a bug where manage collections wasn't named correctly in UI.

* Slight tweaks on email flow

* Fixed a bug where we were grabbing wrong property for book layout mode

* Fixed an issue where pagination area wasn't properly spanning window on different scaling modes.

* Fixed a bug where right pagination area wasn't sticking to the right hand side on original scaling

* Added a note from reading an epub3

* Reworked some of the readme

* Bump versions by dotnet-bump-version.

* Small Build Fix (#1795)

* Stop showing loading indicator when no next/prev chapter

* Fixed a bug where manage collections wasn't named correctly in UI.

* Slight tweaks on email flow

* Fixed a bug where we were grabbing wrong property for book layout mode

* Fixed an issue where pagination area wasn't properly spanning window on different scaling modes.

* Fixed a bug where right pagination area wasn't sticking to the right hand side on original scaling

* Added a note from reading an epub3

* Reworked some of the readme

* Changed the build to ci

* Bump versions by dotnet-bump-version.

* Final Release Testing (#1796)

* Fix some wording

* Fixed up stats to have total info on hover

* Fixed up a stat card not having clickable hint

* Bump versions by dotnet-bump-version.

* v0.7 Release

* Version bump

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Robbie Davis <robbie@therobbiedavis.com>
Co-authored-by: Mike <github@emailisgood.com>
Co-authored-by: Muggz <mug@passw0rd.org>
Co-authored-by: Domenic Fiore <DomenicF@users.noreply.github.com>
Co-authored-by: Kupferhirn <kupferhirn@brokensoft.net>
This commit is contained in:
Joe Milazzo 2023-02-18 09:01:04 -06:00 committed by GitHub
parent 787386c6e9
commit a47545fc72
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
598 changed files with 72415 additions and 13267 deletions

View File

@ -22,6 +22,10 @@ jobs:
with:
dotnet-version: 6.0.x
- name: Install Swashbuckle CLI
shell: powershell
run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli
- name: Install dependencies
run: dotnet restore
@ -35,29 +39,6 @@ jobs:
name: csproj
path: Kavita.Common/Kavita.Common.csproj
test:
name: Install Sonar & Test
needs: build
runs-on: windows-latest
steps:
- name: Checkout Repo
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Setup .NET Core
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Install dependencies
run: dotnet restore
- name: Set up JDK 11
uses: actions/setup-java@v1
with:
java-version: 1.11
- name: Cache SonarCloud packages
uses: actions/cache@v1
with:
@ -93,9 +74,10 @@ jobs:
- name: Test
run: dotnet test --no-restore --verbosity normal
version:
name: Bump version on Develop push
needs: [ build, test ]
needs: [ build ]
runs-on: ubuntu-latest
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
steps:
@ -108,6 +90,9 @@ jobs:
with:
dotnet-version: 6.0.x
- name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli
- name: Install dependencies
run: dotnet restore
@ -165,7 +150,7 @@ jobs:
- run: |
cd UI/Web || exit
echo 'Installing web dependencies'
npm install
npm ci
echo 'Building UI'
npm run prod
@ -194,6 +179,10 @@ jobs:
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli
- run: ./monorepo-build.sh
- name: Login to Docker Hub
@ -307,6 +296,9 @@ jobs:
uses: actions/setup-dotnet@v2
with:
dotnet-version: 6.0.x
- name: Install Swashbuckle CLI
run: dotnet tool install -g --version 6.4.0 Swashbuckle.AspNetCore.Cli
- run: ./monorepo-build.sh
- name: Login to Docker Hub

4
.gitignore vendored
View File

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

View File

@ -1,9 +1,14 @@
using System;
using System.IO;
using System.IO.Abstractions;
using Microsoft.Extensions.Logging.Abstractions;
using API.Services;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Png;
using SixLabors.ImageSharp.Formats.Webp;
using SixLabors.ImageSharp.Processing;
namespace API.Benchmark;
@ -17,6 +22,10 @@ public class ArchiveServiceBenchmark
private readonly ArchiveService _archiveService;
private readonly IDirectoryService _directoryService;
private readonly IImageService _imageService;
private readonly PngEncoder _pngEncoder = new PngEncoder();
private readonly WebpEncoder _webPEncoder = new WebpEncoder();
private const string SourceImage = "C:/Users/josep/Pictures/obey_by_grrsa-d6llkaa_colored_by_me.png";
public ArchiveServiceBenchmark()
{
@ -49,6 +58,52 @@ public class ArchiveServiceBenchmark
}
}
[Benchmark]
public void ImageSharp_ExtractImage_PNG()
{
var outputDirectory = "C:/Users/josep/Pictures/imagesharp/";
_directoryService.ExistOrCreate(outputDirectory);
using var stream = new FileStream(SourceImage, FileMode.Open);
using var thumbnail2 = SixLabors.ImageSharp.Image.Load(stream);
thumbnail2.Mutate(x => x.Resize(320, 0));
thumbnail2.Save(_directoryService.FileSystem.Path.Join(outputDirectory, "imagesharp.png"), _pngEncoder);
}
[Benchmark]
public void ImageSharp_ExtractImage_WebP()
{
var outputDirectory = "C:/Users/josep/Pictures/imagesharp/";
_directoryService.ExistOrCreate(outputDirectory);
using var stream = new FileStream(SourceImage, FileMode.Open);
using var thumbnail2 = SixLabors.ImageSharp.Image.Load(stream);
thumbnail2.Mutate(x => x.Resize(320, 0));
thumbnail2.Save(_directoryService.FileSystem.Path.Join(outputDirectory, "imagesharp.webp"), _webPEncoder);
}
[Benchmark]
public void NetVips_ExtractImage_PNG()
{
var outputDirectory = "C:/Users/josep/Pictures/netvips/";
_directoryService.ExistOrCreate(outputDirectory);
using var stream = new FileStream(SourceImage, FileMode.Open);
using var thumbnail = NetVips.Image.ThumbnailStream(stream, 320);
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, "netvips.png"));
}
[Benchmark]
public void NetVips_ExtractImage_WebP()
{
var outputDirectory = "C:/Users/josep/Pictures/netvips/";
_directoryService.ExistOrCreate(outputDirectory);
using var stream = new FileStream(SourceImage, FileMode.Open);
using var thumbnail = NetVips.Image.ThumbnailStream(stream, 320);
thumbnail.WriteToFile(_directoryService.FileSystem.Path.Join(outputDirectory, "netvips.webp"));
}
// Benchmark to test default GetNumberOfPages from archive
// vs a new method where I try to open the archive and return said stream
}

View File

@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using API.Services;
using BenchmarkDotNet.Attributes;
@ -9,34 +10,58 @@ using VersOne.Epub;
namespace API.Benchmark;
[StopOnFirstError]
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[RankColumn]
[SimpleJob(launchCount: 1, warmupCount: 3, targetCount: 5, invocationCount: 100, id: "Epub"), ShortRunJob]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
[SimpleJob(launchCount: 1, warmupCount: 5, targetCount: 20)]
public class EpubBenchmark
{
private const string FilePath = @"E:\Books\Invaders of the Rokujouma\Invaders of the Rokujouma - Volume 01.epub";
private readonly Regex WordRegex = new Regex(@"\b\w+\b", RegexOptions.Compiled | RegexOptions.IgnoreCase);
// [Benchmark]
// public async Task GetWordCount_PassByString()
// {
// using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
// foreach (var bookFile in book.Content.Html.Values)
// {
// GetBookWordCount_PassByString(await bookFile.ReadContentAsTextAsync());
// ;
// }
// }
[Benchmark]
public static async Task GetWordCount_PassByString()
public async Task GetWordCount_PassByRef()
{
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
Console.WriteLine(GetBookWordCount_PassByString(await bookFile.ReadContentAsTextAsync()));
;
await GetBookWordCount_PassByRef(bookFile);
}
}
[Benchmark]
public static async Task GetWordCount_PassByRef()
public async Task GetBookWordCount_SumEarlier()
{
using var book = await EpubReader.OpenBookAsync("Data/book-test.epub", BookService.BookReaderOptions);
using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
Console.WriteLine(await GetBookWordCount_PassByRef(bookFile));
await GetBookWordCount_SumEarlier(bookFile);
}
}
private static int GetBookWordCount_PassByString(string fileContents)
[Benchmark]
public async Task GetBookWordCount_Regex()
{
using var book = await EpubReader.OpenBookAsync(FilePath, BookService.BookReaderOptions);
foreach (var bookFile in book.Content.Html.Values)
{
await GetBookWordCount_Regex(bookFile);
}
}
private int GetBookWordCount_PassByString(string fileContents)
{
var doc = new HtmlDocument();
doc.LoadHtml(fileContents);
@ -51,18 +76,41 @@ public class EpubBenchmark
.Sum();
}
private static async Task<int> GetBookWordCount_PassByRef(EpubContentFileRef bookFile)
private async Task<int> GetBookWordCount_PassByRef(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
var delimiter = new char[] {' '};
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.Select(node => node.InnerText)
var textNodes = doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]");
if (textNodes == null) return 0;
return textNodes.Select(node => node.InnerText)
.Select(text => text.Split(delimiter, StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Select(words => words.Count())
.Where(wordCount => wordCount > 0)
.Sum();
}
private async Task<int> GetBookWordCount_SumEarlier(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.DefaultIfEmpty()
.Select(node => node.InnerText.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Where(s => char.IsLetter(s[0])))
.Sum(words => words.Count());
}
private async Task<int> GetBookWordCount_Regex(EpubContentFileRef bookFile)
{
var doc = new HtmlDocument();
doc.LoadHtml(await bookFile.ReadContentAsTextAsync());
return doc.DocumentNode.SelectNodes("//body//text()[not(parent::script)]")
.Sum(node => WordRegex.Matches(node.InnerText).Count);
}
}

View File

@ -9,6 +9,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="6.0.10" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.2" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="NSubstitute" Version="4.4.0" />
<PackageReference Include="System.IO.Abstractions.TestingHelpers" Version="17.2.3" />
<PackageReference Include="xunit" Version="2.4.2" />

View File

@ -17,9 +17,9 @@ using NSubstitute;
namespace API.Tests;
public abstract class BasicTest
public abstract class AbstractDbTest
{
private readonly DbConnection _connection;
protected readonly DbConnection _connection;
protected readonly DataContext _context;
protected readonly IUnitOfWork _unitOfWork;
@ -30,8 +30,9 @@ public abstract class BasicTest
protected const string LogDirectory = "C:/kavita/config/logs/";
protected const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
protected const string TempDirectory = "C:/kavita/config/temp/";
protected const string DataDirectory = "C:/data/";
protected BasicTest()
protected AbstractDbTest()
{
var contextOptions = new DbContextOptionsBuilder()
.UseSqlite(CreateInMemoryDatabase())
@ -50,7 +51,6 @@ public abstract class BasicTest
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
@ -86,19 +86,13 @@ public abstract class BasicTest
{
Path = "C:/data/"
}
}
},
Series = new List<Series>()
});
return await _context.SaveChangesAsync() > 0;
}
protected async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
_context.Users.RemoveRange(_context.Users.ToList());
_context.AppUserBookmark.RemoveRange(_context.AppUserBookmark.ToList());
await _context.SaveChangesAsync();
}
protected abstract Task ResetDb();
protected static MockFileSystem CreateFileSystem()
{
@ -111,7 +105,7 @@ public abstract class BasicTest
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory(LogDirectory);
fileSystem.AddDirectory(TempDirectory);
fileSystem.AddDirectory("C:/data/");
fileSystem.AddDirectory(DataDirectory);
return fileSystem;
}

View File

@ -12,6 +12,6 @@ public class SortComparerZeroLastTests
[InlineData(new[] {0, 0, 1}, new[] {1, 0, 0})]
public void SortComparerZeroLastTest(int[] input, int[] expected)
{
Assert.Equal(expected, input.OrderBy(f => f, new SortComparerZeroLast()).ToArray());
Assert.Equal(expected, input.OrderBy(f => f, SortComparerZeroLast.Default).ToArray());
}
}

View File

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using API.Data;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
@ -60,23 +61,8 @@ public static class EntityFactory
};
}
public static SeriesMetadata CreateSeriesMetadata(ICollection<CollectionTag> collectionTags)
{
return new SeriesMetadata()
{
CollectionTags = collectionTags
};
}
public static CollectionTag CreateCollectionTag(int id, string title, string summary, bool promoted)
{
return new CollectionTag()
{
Id = id,
NormalizedTitle = API.Services.Tasks.Scanner.Parser.Parser.Normalize(title).ToUpper(),
Title = title,
Summary = summary,
Promoted = promoted
};
return DbFactory.CollectionTag(id, title, summary, promoted);
}
}

View File

@ -13,13 +13,13 @@ public class GenreHelperTests
{
var allGenres = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("action", false),
DbFactory.Genre("Sci-fi", false),
DbFactory.Genre("Action"),
DbFactory.Genre("action"),
DbFactory.Genre("Sci-fi"),
};
var genreAdded = new List<Genre>();
GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, false, genre =>
GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Adventure"}, genre =>
{
genreAdded.Add(genre);
});
@ -33,19 +33,20 @@ public class GenreHelperTests
{
var allGenres = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("action", false),
DbFactory.Genre("Sci-fi", false),
DbFactory.Genre("Action"),
DbFactory.Genre("action"),
DbFactory.Genre("Sci-fi"),
};
var genreAdded = new List<Genre>();
GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, false, genre =>
GenreHelper.UpdateGenre(allGenres, new[] {"Action", "Scifi"}, genre =>
{
genreAdded.Add(genre);
});
Assert.Equal(3, allGenres.Count);
Assert.Equal(2, genreAdded.Count);
}
[Fact]
@ -53,49 +54,34 @@ public class GenreHelperTests
{
var existingGenres = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("action", false),
DbFactory.Genre("Sci-fi", false),
DbFactory.Genre("Action"),
DbFactory.Genre("action"),
DbFactory.Genre("Sci-fi"),
};
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", false));
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action"));
Assert.Equal(3, existingGenres.Count);
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("action", false));
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("action"));
Assert.Equal(3, existingGenres.Count);
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Shonen", false));
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Shonen"));
Assert.Equal(4, existingGenres.Count);
}
[Fact]
public void AddGenre_ShouldNotAddSameNameAndExternal()
{
var existingGenres = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("action", false),
DbFactory.Genre("Sci-fi", false),
};
GenreHelper.AddGenreIfNotExists(existingGenres, DbFactory.Genre("Action", true));
Assert.Equal(3, existingGenres.Count);
}
[Fact]
public void KeepOnlySamePeopleBetweenLists()
{
var existingGenres = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("Sci-fi", false),
DbFactory.Genre("Action"),
DbFactory.Genre("Sci-fi"),
};
var peopleFromChapters = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("Action"),
};
var genreRemoved = new List<Genre>();
@ -113,8 +99,8 @@ public class GenreHelperTests
{
var existingGenres = new List<Genre>
{
DbFactory.Genre("Action", false),
DbFactory.Genre("Sci-fi", false),
DbFactory.Genre("Action"),
DbFactory.Genre("Sci-fi"),
};
var peopleFromChapters = new List<Genre>();

View File

@ -103,7 +103,7 @@ public class PersonHelperTests
DbFactory.Person("Joe Shmo", PersonRole.CoverArtist)
};
var peopleRemoved = new List<Person>();
PersonHelper.RemovePeople(existingPeople, Array.Empty<string>(), PersonRole.Writer, person =>
PersonHelper.RemovePeople(existingPeople, new List<string>(), PersonRole.Writer, person =>
{
peopleRemoved.Add(person);
});

View File

@ -13,13 +13,13 @@ public class TagHelperTests
{
var allTags = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("action", false),
DbFactory.Tag("Sci-fi", false),
DbFactory.Tag("Action"),
DbFactory.Tag("action"),
DbFactory.Tag("Sci-fi"),
};
var tagAdded = new List<Tag>();
TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, false, (tag, added) =>
TagHelper.UpdateTag(allTags, new[] {"Action", "Adventure"}, (tag, added) =>
{
if (added)
{
@ -37,14 +37,14 @@ public class TagHelperTests
{
var allTags = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("action", false),
DbFactory.Tag("Sci-fi", false),
DbFactory.Tag("Action"),
DbFactory.Tag("action"),
DbFactory.Tag("Sci-fi"),
};
var tagAdded = new List<Tag>();
TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, false, (tag, added) =>
TagHelper.UpdateTag(allTags, new[] {"Action", "Scifi"}, (tag, added) =>
{
if (added)
{
@ -62,49 +62,34 @@ public class TagHelperTests
{
var existingTags = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("action", false),
DbFactory.Tag("Sci-fi", false),
DbFactory.Tag("Action"),
DbFactory.Tag("action"),
DbFactory.Tag("Sci-fi"),
};
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", false));
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action"));
Assert.Equal(3, existingTags.Count);
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action", false));
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("action"));
Assert.Equal(3, existingTags.Count);
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen", false));
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Shonen"));
Assert.Equal(4, existingTags.Count);
}
[Fact]
public void AddTag_ShouldNotAddSameNameAndExternal()
{
var existingTags = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("action", false),
DbFactory.Tag("Sci-fi", false),
};
TagHelper.AddTagIfNotExists(existingTags, DbFactory.Tag("Action", true));
Assert.Equal(3, existingTags.Count);
}
[Fact]
public void KeepOnlySamePeopleBetweenLists()
{
var existingTags = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("Sci-fi", false),
DbFactory.Tag("Action"),
DbFactory.Tag("Sci-fi"),
};
var peopleFromChapters = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("Action"),
};
var tagRemoved = new List<Tag>();
@ -122,8 +107,8 @@ public class TagHelperTests
{
var existingTags = new List<Tag>
{
DbFactory.Tag("Action", false),
DbFactory.Tag("Sci-fi", false),
DbFactory.Tag("Action"),
DbFactory.Tag("Sci-fi"),
};
var peopleFromChapters = new List<Tag>();

View File

@ -100,6 +100,7 @@ public class ComicParserTests
[InlineData("Teen Titans v1 001 (1966-02) (digital) (OkC.O.M.P.U.T.O.-Novus)", "1")]
[InlineData("Scott Pilgrim 02 - Scott Pilgrim vs. The World (2005)", "0")]
[InlineData("Superman v1 024 (09-10 1943)", "1")]
[InlineData("Superman v1.5 024 (09-10 1943)", "1.5")]
[InlineData("Amazing Man Comics chapter 25", "0")]
[InlineData("Invincible 033.5 - Marvel Team-Up 14 (2006) (digital) (Minutemen-Slayer)", "0")]
[InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")]
@ -118,6 +119,7 @@ public class ComicParserTests
[InlineData("Cyberpunk 2077 - Trauma Team 04.cbz", "0")]
[InlineData("2000 AD 0366 [1984-04-28] (flopbie)", "0")]
[InlineData("Daredevil - v6 - 10 - (2019)", "6")]
[InlineData("Daredevil - v6.5", "6.5")]
// Tome Tests
[InlineData("Daredevil - t6 - 10 - (2019)", "6")]
[InlineData("Batgirl T2000 #57", "2000")]

View File

@ -41,7 +41,7 @@ internal class MockReadingItemServiceForCacheService : IReadingItemService
return 1;
}
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format)
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP)
{
return string.Empty;
}
@ -325,7 +325,7 @@ public class CacheServiceTests
// Flatten to prepare for how GetFullPath expects
ds.Flatten($"{CacheDirectory}1/");
var path = cs.GetCachedPagePath(c, 11);
var path = cs.GetCachedPagePath(c.Id, 11);
Assert.Equal(string.Empty, path);
}
@ -377,7 +377,7 @@ public class CacheServiceTests
// Flatten to prepare for how GetFullPath expects
ds.Flatten($"{CacheDirectory}1/");
Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_001.jpg"), ds.FileSystem.Path.GetFullPath(cs.GetCachedPagePath(c, 0)));
Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_001.jpg"), ds.FileSystem.Path.GetFullPath(cs.GetCachedPagePath(c.Id, 0)));
}
@ -425,7 +425,7 @@ public class CacheServiceTests
ds.Flatten($"{CacheDirectory}1/");
// Remember that we start at 0, so this is the 10th file
var path = cs.GetCachedPagePath(c, c.Pages);
var path = cs.GetCachedPagePath(c.Id, c.Pages);
Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/000_0{c.Pages}.jpg"), ds.FileSystem.Path.GetFullPath(path));
}
@ -478,7 +478,7 @@ public class CacheServiceTests
ds.Flatten($"{CacheDirectory}1/");
// Remember that we start at 0, so this is the page + 1 file
var path = cs.GetCachedPagePath(c, 10);
var path = cs.GetCachedPagePath(c.Id, 10);
Assert.Equal(ds.FileSystem.Path.GetFullPath($"{CacheDirectory}/1/001_001.jpg"), ds.FileSystem.Path.GetFullPath(path));
}

View File

@ -6,9 +6,12 @@ using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.Filtering;
using API.DTOs.Settings;
using API.Entities;
using API.Entities.Enums;
using API.Entities.Metadata;
using API.Helpers;
using API.Helpers.Converters;
using API.Services;
@ -26,70 +29,14 @@ using Xunit;
namespace API.Tests.Services;
public class CleanupServiceTests
public class CleanupServiceTests : AbstractDbTest
{
private readonly ILogger<CleanupService> _logger = Substitute.For<ILogger<CleanupService>>();
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _messageHub = Substitute.For<IEventHub>();
private readonly DbConnection _connection;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string LogDirectory = "C:/kavita/config/logs/";
private const string BookmarkDirectory = "C:/kavita/config/bookmarks/";
public CleanupServiceTests()
public CleanupServiceTests() : base()
{
var contextOptions = new DbContextOptionsBuilder()
.UseSqlite(CreateInMemoryDatabase())
.Options;
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null);
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context, new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BookmarkDirectory).SingleAsync();
setting.Value = BookmarkDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.TotalLogs).SingleAsync();
setting.Value = "10";
_context.ServerSetting.Update(setting);
_context.Library.Add(new Library()
{
Name = "Manga",
@ -101,10 +48,12 @@ public class CleanupServiceTests
}
}
});
return await _context.SaveChangesAsync() > 0;
}
private async Task ResetDB()
#region Setup
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
_context.Users.RemoveRange(_context.Users.ToList());
@ -113,23 +62,8 @@ public class CleanupServiceTests
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(BookmarkDirectory);
fileSystem.AddDirectory("C:/data/");
return fileSystem;
}
#endregion
#region DeleteSeriesCoverImages
[Fact]
@ -141,7 +75,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData(""));
// Delete all Series to reset state
await ResetDB();
await ResetDb();
var s = DbFactory.Series("Test 1");
s.CoverImage = $"{ImageService.GetSeriesFormat(1)}.jpg";
@ -174,7 +108,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetSeriesFormat(1000)}.jpg", new MockFileData(""));
// Delete all Series to reset state
await ResetDB();
await ResetDb();
// Add 2 series with cover images
var s = DbFactory.Series("Test 1");
@ -208,7 +142,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}v01_c1000.jpg", new MockFileData(""));
// Delete all Series to reset state
await ResetDB();
await ResetDb();
// Add 2 series with cover images
var s = DbFactory.Series("Test 1");
@ -258,7 +192,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetCollectionTagFormat(1000)}.jpg", new MockFileData(""));
// Delete all Series to reset state
await ResetDB();
await ResetDb();
// Add 2 series with cover images
var s = DbFactory.Series("Test 1");
@ -306,7 +240,7 @@ public class CleanupServiceTests
filesystem.AddFile($"{CoverImageDirectory}{ImageService.GetReadingListFormat(3)}.jpg", new MockFileData(""));
// Delete all Series to reset state
await ResetDB();
await ResetDb();
_context.Users.Add(new AppUser()
{
@ -469,6 +403,162 @@ public class CleanupServiceTests
#endregion
#region CleanupDbEntries
[Fact]
public async Task CleanupDbEntries_CleanupAbandonedChapters()
{
var c = EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1);
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
c,
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.Progress);
await readerService.MarkChaptersUntilAsRead(user, 1, 5);
await _context.SaveChangesAsync();
// Validate correct chapters have read status
Assert.Equal(1, (await _unitOfWork.AppUserProgressRepository.GetUserProgressAsync(1, 1)).PagesRead);
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
// Delete the Chapter
_context.Chapter.Remove(c);
await _unitOfWork.CommitAsync();
Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1));
// NOTE: This may not be needed, the underlying DB structure seems fixed as of v0.7
await cleanupService.CleanupDbEntries();
Assert.Empty(await _unitOfWork.AppUserProgressRepository.GetUserProgressForSeriesAsync(1, 1));
}
[Fact]
public async Task CleanupDbEntries_RemoveTagsWithoutSeries()
{
var c = new CollectionTag()
{
Title = "Test Tag"
};
var s = new Series()
{
Name = "Test",
Library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>(),
Metadata = new SeriesMetadata()
{
CollectionTags = new List<CollectionTag>()
{
c
}
}
};
_context.Series.Add(s);
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
// Delete the Chapter
_context.Series.Remove(s);
await _unitOfWork.CommitAsync();
await cleanupService.CleanupDbEntries();
Assert.Empty(await _unitOfWork.CollectionTagRepository.GetAllTagsAsync());
}
#endregion
#region CleanupWantToRead
[Fact]
public async Task CleanupWantToRead_ShouldRemoveFullyReadSeries()
{
await ResetDb();
var s = new Series()
{
Name = "Test CleanupWantToRead_ShouldRemoveFullyReadSeries",
Library = new Library()
{
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>(),
Metadata = new SeriesMetadata()
{
PublicationStatus = PublicationStatus.Completed
}
};
_context.Series.Add(s);
var user = new AppUser()
{
UserName = "CleanupWantToRead_ShouldRemoveFullyReadSeries",
WantToRead = new List<Series>()
{
s
}
};
_context.AppUser.Add(user);
await _unitOfWork.CommitAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>());
await readerService.MarkSeriesAsRead(user, s.Id);
await _unitOfWork.CommitAsync();
var cleanupService = new CleanupService(Substitute.For<ILogger<CleanupService>>(), _unitOfWork,
Substitute.For<IEventHub>(),
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), new MockFileSystem()));
await cleanupService.CleanupWantToRead();
var wantToRead =
await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, new UserParams(), new FilterDto());
Assert.Equal(0, wantToRead.TotalCount);
}
#endregion
// #region CleanupBookmarks
//
// [Fact]
@ -479,7 +569,7 @@ public class CleanupServiceTests
// filesystem.AddFile($"{BookmarkDirectory}1/1/1/0002.jpg", new MockFileData(""));
//
// // Delete all Series to reset state
// await ResetDB();
// await ResetDb();
//
// _context.Series.Add(new Series()
// {
@ -551,7 +641,7 @@ public class CleanupServiceTests
// filesystem.AddFile($"{BookmarkDirectory}1/1/2/0002.jpg", new MockFileData(""));
//
// // Delete all Series to reset state
// await ResetDB();
// await ResetDb();
//
// _context.Series.Add(new Series()
// {

View File

@ -0,0 +1,155 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.CollectionTags;
using API.Entities;
using API.Entities.Enums;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using API.Tests.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class CollectionTagServiceTests : AbstractDbTest
{
private readonly ICollectionTagService _service;
public CollectionTagServiceTests()
{
_service = new CollectionTagService(_unitOfWork, Substitute.For<IEventHub>());
}
protected override async Task ResetDb()
{
_context.CollectionTag.RemoveRange(_context.CollectionTag.ToList());
_context.Library.RemoveRange(_context.Library.ToList());
await _unitOfWork.CommitAsync();
}
private async Task SeedSeries()
{
if (_context.CollectionTag.Any()) return;
_context.Library.Add(new Library()
{
Name = "Library 2",
Type = LibraryType.Manga,
Series = new List<Series>()
{
EntityFactory.CreateSeries("Series 1"),
EntityFactory.CreateSeries("Series 2"),
}
});
_context.CollectionTag.Add(DbFactory.CollectionTag(0, "Tag 1", string.Empty, false));
_context.CollectionTag.Add(DbFactory.CollectionTag(0, "Tag 2", string.Empty, true));
await _unitOfWork.CommitAsync();
}
[Fact]
public async Task TagExistsByName_ShouldFindTag()
{
await SeedSeries();
Assert.True(await _service.TagExistsByName("Tag 1"));
Assert.True(await _service.TagExistsByName("tag 1"));
Assert.False(await _service.TagExistsByName("tag5"));
}
[Fact]
public async Task UpdateTag_ShouldUpdateFields()
{
await SeedSeries();
_context.CollectionTag.Add(EntityFactory.CreateCollectionTag(3, "UpdateTag_ShouldUpdateFields",
string.Empty, true));
await _unitOfWork.CommitAsync();
await _service.UpdateTag(new CollectionTagDto()
{
Title = "UpdateTag_ShouldUpdateFields",
Id = 3,
Promoted = true,
Summary = "Test Summary",
});
var tag = await _unitOfWork.CollectionTagRepository.GetTagAsync(3);
Assert.NotNull(tag);
Assert.True(tag.Promoted);
Assert.True(!string.IsNullOrEmpty(tag.Summary));
}
[Fact]
public async Task AddTagToSeries_ShouldAddTagToAllSeries()
{
await SeedSeries();
var ids = new[] {1, 2};
await _service.AddTagToSeries(await _unitOfWork.CollectionTagRepository.GetFullTagAsync(1), ids);
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(ids);
Assert.True(metadatas.ElementAt(0).CollectionTags.Any(t => t.Title.Equals("Tag 1")));
Assert.True(metadatas.ElementAt(1).CollectionTags.Any(t => t.Title.Equals("Tag 1")));
}
[Fact]
public async Task RemoveTagFromSeries_ShouldRemoveMultiple()
{
await SeedSeries();
var ids = new[] {1, 2};
var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(2);
await _service.AddTagToSeries(tag, ids);
await _service.RemoveTagFromSeries(tag, new[] {1});
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
Assert.Single(metadatas);
Assert.Empty(metadatas.First().CollectionTags);
Assert.NotEmpty(await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {2}));
}
[Fact]
public async Task GetTagOrCreate_ShouldReturnNewTag()
{
await SeedSeries();
var tag = await _service.GetTagOrCreate(0, "GetTagOrCreate_ShouldReturnNewTag");
Assert.NotNull(tag);
Assert.NotSame(0, tag.Id);
}
[Fact]
public async Task GetTagOrCreate_ShouldReturnExistingTag()
{
await SeedSeries();
var tag = await _service.GetTagOrCreate(1, string.Empty);
Assert.NotNull(tag);
Assert.NotSame(1, tag.Id);
}
[Fact]
public async Task RemoveTagsWithoutSeries_ShouldRemoveAbandonedEntries()
{
await SeedSeries();
// Setup a tag with one series
var tag = await _service.GetTagOrCreate(0, "Tag with a series");
await _unitOfWork.CommitAsync();
var metadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(new[] {1});
tag.SeriesMetadatas.Add(metadatas.First());
var tagId = tag.Id;
await _unitOfWork.CommitAsync();
// Validate it doesn't remove tags it shouldn't
await _service.RemoveTagsWithoutSeries();
Assert.NotNull(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
await _service.RemoveTagFromSeries(tag, new[] {1});
// Validate it does remove tags it should
await _service.RemoveTagsWithoutSeries();
Assert.Null(await _unitOfWork.CollectionTagRepository.GetTagAsync(tagId));
}
}

View File

@ -12,20 +12,20 @@ using Xunit;
namespace API.Tests.Services;
public class DeviceServiceTests : BasicTest
public class DeviceServiceDbTests : AbstractDbTest
{
private readonly ILogger<DeviceService> _logger = Substitute.For<ILogger<DeviceService>>();
private readonly IDeviceService _deviceService;
public DeviceServiceTests() : base()
public DeviceServiceDbTests() : base()
{
_deviceService = new DeviceService(_unitOfWork, _logger, Substitute.For<IEmailService>());
}
protected new Task ResetDb()
protected override async Task ResetDb()
{
_context.Users.RemoveRange(_context.Users.ToList());
return Task.CompletedTask;
await _unitOfWork.CommitAsync();
}
@ -51,7 +51,6 @@ public class DeviceServiceTests : BasicTest
}, user);
Assert.NotNull(device);
}
[Fact]

View File

@ -46,7 +46,7 @@ internal class MockReadingItemService : IReadingItemService
return 1;
}
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format)
public string GetCoverImage(string fileFilePath, string fileName, MangaFormat format, bool saveAsWebP)
{
return string.Empty;
}

View File

@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
@ -6,6 +7,7 @@ using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Reader;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
@ -18,11 +20,13 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
using Xunit.Abstractions;
namespace API.Tests.Services;
public class ReaderServiceTests
{
private readonly ITestOutputHelper _testOutputHelper;
private readonly IUnitOfWork _unitOfWork;
@ -33,8 +37,9 @@ public class ReaderServiceTests
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string DataDirectory = "C:/data/";
public ReaderServiceTests()
public ReaderServiceTests(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
_context = new DataContext(contextOptions);
@ -1294,8 +1299,6 @@ public class ReaderServiceTests
// This is first chapter of first volume
prevChapter = await readerService.GetPrevChapterIdAsync(1, 2,4, 1);
Assert.Equal(-1, prevChapter);
//chapterInfoDto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(prevChapter);
}
[Fact]
@ -1426,6 +1429,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstVolume_NoProgress()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1473,9 +1477,56 @@ public class ReaderServiceTests
Assert.Equal("1", nextChapter.Range);
}
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstVolume_WhenFirstVolumeIsAlsoTaggedAsChapter1_WithProgress()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 3),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
await readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 2,
ChapterId = 1,
SeriesId = 1,
VolumeId = 1
}, 1);
var nextChapter = await readerService.GetContinuePoint(1, 1);
Assert.Equal("1", nextChapter.Range);
}
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1548,6 +1599,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstNonSpecial2()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1626,6 +1678,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstSpecial()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1695,6 +1748,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenNonRead_LooseLeafChaptersAndVolumes()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1734,9 +1788,77 @@ public class ReaderServiceTests
Assert.Equal("1", nextChapter.Range);
}
[Fact]
public async Task GetContinuePoint_ShouldReturnLooseChapter_WhenAllVolumesAndAFewLooseChaptersRead()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("100", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("101", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("102", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
EntityFactory.CreateChapter("1", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("2", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
EntityFactory.CreateChapter("21", false, new List<MangaFile>(), 1),
}),
}
});
var user = new AppUser()
{
UserName = "majora2007"
};
_context.AppUser.Add(user);
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
// Mark everything but chapter 101 as read
await readerService.MarkSeriesAsRead(user, 1);
await _unitOfWork.CommitAsync();
// Unmark last chapter as read
await readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 0,
ChapterId = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1)).Chapters.ElementAt(1).Id,
SeriesId = 1,
VolumeId = 1
}, 1);
await readerService.SaveReadingProgress(new ProgressDto()
{
PageNum = 0,
ChapterId = (await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(1)).Chapters.ElementAt(2).Id,
SeriesId = 1,
VolumeId = 1
}, 1);
await _context.SaveChangesAsync();
var nextChapter = await readerService.GetContinuePoint(1, 1);
Assert.Equal("101", nextChapter.Range);
}
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllRead()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1800,6 +1922,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstChapter_WhenAllReadAndAllChapters()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1845,6 +1968,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstSpecial_WhenAllReadAndAllChapters()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -1906,6 +2030,7 @@ public class ReaderServiceTests
[Fact]
public async Task GetContinuePoint_ShouldReturnFirstVolumeChapter_WhenPreExistingProgress()
{
await ResetDb();
var series = new Series()
{
Name = "Test",
@ -1954,10 +2079,84 @@ public class ReaderServiceTests
_context.Series.Attach(series);
await _context.SaveChangesAsync();
// This tests that if you add a series later to a volume and a loose leaf chapter, we continue from that volume, rather than loose leaf
var nextChapter = await readerService.GetContinuePoint(1, 1);
Assert.Equal("14.9", nextChapter.Range);
}
[Fact]
public async Task GetContinuePoint_ShouldReturnUnreadSingleVolume_WhenThereAreSomeSingleVolumesBeforeLooseLeafChapters()
{
await ResetDb();
var readChapter1 = EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1);
var readChapter2 = EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1);
var volume = EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
});
_context.Series.Add(new Series()
{
Name = "Test",
Library = new Library() {
Name = "Test LIb",
Type = LibraryType.Manga,
},
Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>()
{
EntityFactory.CreateChapter("51", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("52", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("53", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("1", new List<Chapter>()
{
readChapter1
}),
EntityFactory.CreateVolume("2", new List<Chapter>()
{
readChapter2
}),
volume,
// 3, 4, and all loose leafs are unread should be unread
EntityFactory.CreateVolume("3", new List<Chapter>()
{
EntityFactory.CreateChapter("0", false, new List<MangaFile>(), 1),
}),
EntityFactory.CreateVolume("4", new List<Chapter>()
{
EntityFactory.CreateChapter("40", false, new List<MangaFile>(), 1),
EntityFactory.CreateChapter("41", false, new List<MangaFile>(), 1),
}),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007"
});
await _context.SaveChangesAsync();
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
// Save progress on first volume chapters and 1st of second volume
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.Progress);
await readerService.MarkChaptersAsRead(user, 1,
new List<Chapter>()
{
readChapter1, readChapter2
});
await _context.SaveChangesAsync();
var nextChapter = await readerService.GetContinuePoint(1, 1);
Assert.Equal(4, nextChapter.VolumeId);
}
#endregion
#region MarkChaptersUntilAsRead
@ -1965,6 +2164,7 @@ public class ReaderServiceTests
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAllChaptersAsRead()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -2007,6 +2207,7 @@ public class ReaderServiceTests
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkUptTillChapterNumberAsRead()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -2051,6 +2252,7 @@ public class ReaderServiceTests
[Fact]
public async Task MarkChaptersUntilAsRead_ShouldMarkAsRead_OnlyVolumesWithChapter0()
{
await ResetDb();
_context.Series.Add(new Series()
{
Name = "Test",
@ -2290,32 +2492,28 @@ public class ReaderServiceTests
[Fact]
public void FormatChapterName_Manga_Chapter()
{
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var actual = readerService.FormatChapterName(LibraryType.Manga, false, false);
var actual = ReaderService.FormatChapterName(LibraryType.Manga, false, false);
Assert.Equal("Chapter", actual);
}
[Fact]
public void FormatChapterName_Book_Chapter_WithTitle()
{
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var actual = readerService.FormatChapterName(LibraryType.Book, false, false);
var actual = ReaderService.FormatChapterName(LibraryType.Book, false, false);
Assert.Equal("Book", actual);
}
[Fact]
public void FormatChapterName_Comic()
{
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var actual = readerService.FormatChapterName(LibraryType.Comic, false, false);
var actual = ReaderService.FormatChapterName(LibraryType.Comic, false, false);
Assert.Equal("Issue", actual);
}
[Fact]
public void FormatChapterName_Comic_WithHash()
{
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var actual = readerService.FormatChapterName(LibraryType.Comic, true, true);
var actual = ReaderService.FormatChapterName(LibraryType.Comic, true, true);
Assert.Equal("Issue #", actual);
}
@ -2448,4 +2646,46 @@ public class ReaderServiceTests
#endregion
#region GetPairs
[Theory]
[InlineData("No Wides", new [] {false, false, false}, new [] {"0,0", "1,1", "2,1"})]
[InlineData("Test_odd_spread_1.zip", new [] {false, false, false, false, false, true},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5"})]
[InlineData("Test_odd_spread_2.zip", new [] {false, false, false, false, false, true, false, false},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6", "7,6"})]
[InlineData("Test_even_spread_1.zip", new [] {false, false, false, false, false, false, true},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6"})]
[InlineData("Test_even_spread_2.zip", new [] {false, false, false, false, false, false, true, false, false},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6", "7,7", "8,7"})]
[InlineData("Edge_cases_SP01.zip", new [] {true, false, false, false},
new [] {"0,0", "1,1", "2,1", "3,3"})]
[InlineData("Edge_cases_SP02.zip", new [] {false, true, false, false, false},
new [] {"0,0", "1,1", "2,2", "3,2", "4,4"})]
[InlineData("Edge_cases_SP03.zip", new [] {false, false, false, false, false, true, true, false, false, false},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6", "7,7", "8,7", "9,9"})]
[InlineData("Edge_cases_SP04.zip", new [] {false, false, false, false, false, true, false, true, false, false},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6", "7,7", "8,8", "9,8"})]
[InlineData("Edge_cases_SP05.zip", new [] {false, false, false, false, false, true, false, false, true, false},
new [] {"0,0", "1,1", "2,1", "3,3", "4,3", "5,5", "6,6", "7,6", "8,8", "9,9"})]
public void GetPairs_ShouldReturnPairsForNoWideImages(string caseName, IList<bool> wides, IList<string> expectedPairs)
{
var readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(), Substitute.For<IEventHub>());
var files = wides.Select((b, i) => new FileDimensionDto() {PageNumber = i, Height = 1, Width = 1, FileName = string.Empty, IsWide = b}).ToList();
var pairs = readerService.GetPairs(files);
var expectedDict = new Dictionary<int, int>();
foreach (var pair in expectedPairs)
{
var token = pair.Split(',');
expectedDict.Add(int.Parse(token[0]), int.Parse(token[1]));
}
_testOutputHelper.WriteLine("Case: {0}", caseName);
_testOutputHelper.WriteLine("Expected: {0}", string.Join(", ", expectedDict.Select(kvp => $"{kvp.Key}->{kvp.Value}")));
_testOutputHelper.WriteLine("Actual: {0}", string.Join(", ", pairs.Select(kvp => $"{kvp.Key}->{kvp.Value}")));
Assert.Equal(expectedDict, pairs);
}
#endregion
}

View File

@ -1,16 +1,20 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Data.Common;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs.ReadingLists;
using API.DTOs.ReadingLists.CBL;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services;
using API.SignalR;
using API.Tests.Helpers;
using AutoMapper;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
@ -24,7 +28,6 @@ public class ReadingListServiceTests
{
private readonly IUnitOfWork _unitOfWork;
private readonly IReadingListService _readingListService;
private readonly DataContext _context;
private const string CacheDirectory = "C:/kavita/config/cache/";
@ -43,7 +46,7 @@ public class ReadingListServiceTests
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null);
_readingListService = new ReadingListService(_unitOfWork, Substitute.For<ILogger<ReadingListService>>());
_readingListService = new ReadingListService(_unitOfWork, Substitute.For<ILogger<ReadingListService>>(), Substitute.For<IEventHub>());
}
#region Setup
@ -83,6 +86,7 @@ public class ReadingListServiceTests
private async Task ResetDb()
{
_context.AppUser.RemoveRange(_context.AppUser);
_context.Library.RemoveRange(_context.Library);
_context.Series.RemoveRange(_context.Series);
_context.ReadingList.RemoveRange(_context.ReadingList);
await _unitOfWork.CommitAsync();
@ -103,8 +107,148 @@ public class ReadingListServiceTests
#endregion
#region AddChaptersToReadingList
[Fact]
public async Task AddChaptersToReadingList_ShouldAddFirstItem_AsOrderZero()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
AgeRating = AgeRating.Everyone,
},
new Chapter()
{
Number = "2",
AgeRating = AgeRating.X18Plus
},
new Chapter()
{
Number = "3",
AgeRating = AgeRating.X18Plus
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1}, readingList);
await _unitOfWork.CommitAsync();
Assert.Equal(1, readingList.Items.Count);
Assert.Equal(0, readingList.Items.First().Order);
}
[Fact]
public async Task AddChaptersToReadingList_ShouldNewItems_AfterLastOrder()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
AgeRating = AgeRating.Everyone,
},
new Chapter()
{
Number = "2",
AgeRating = AgeRating.X18Plus
},
new Chapter()
{
Number = "3",
AgeRating = AgeRating.X18Plus
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1}, readingList);
await _unitOfWork.CommitAsync();
await _readingListService.AddChaptersToReadingList(1, new List<int>() {2}, readingList);
await _unitOfWork.CommitAsync();
Assert.Equal(2, readingList.Items.Count);
Assert.Equal(0, readingList.Items.First().Order);
Assert.Equal(1, readingList.Items.ElementAt(1).Order);
}
#endregion
#region UpdateReadingListItemPosition
[Fact]
public async Task UpdateReadingListItemPosition_MoveLastToFirst_TwoItemsShouldShift()
{
@ -181,6 +325,96 @@ public class ReadingListServiceTests
Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order);
}
[Fact]
public async Task UpdateReadingListItemPosition_MoveLastToFirst_TwoItemsShouldShift_ThenDeleteSecond_OrderShouldBeCorrect()
{
await ResetDb();
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
AgeRating = AgeRating.Everyone,
},
new Chapter()
{
Number = "2",
AgeRating = AgeRating.X18Plus
},
new Chapter()
{
Number = "3",
AgeRating = AgeRating.X18Plus
}
}
}
}
}
}
},
}
});
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
var readingList = new ReadingList();
user.ReadingLists = new List<ReadingList>()
{
readingList
};
// Existing (order, chapterId): (0, 1), (1, 2), (2, 3)
await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 2, 3}, readingList);
await _unitOfWork.CommitAsync();
Assert.Equal(3, readingList.Items.Count);
// From 3 to 1
// New (order, chapterId): (0, 3), (1, 2), (2, 1)
await _readingListService.UpdateReadingListItemPosition(new UpdateReadingListPosition()
{
FromPosition = 2, ToPosition = 0, ReadingListId = 1, ReadingListItemId = 3
});
Assert.Equal(3, readingList.Items.Count);
Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order);
Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order);
Assert.Equal(2, readingList.Items.Single(i => i.ChapterId == 2).Order);
// New (order, chapterId): (0, 3), (2, 1): Delete 2nd item
await _readingListService.DeleteReadingListItem(new UpdateReadingListPosition()
{
ReadingListId = 1, ReadingListItemId = readingList.Items.Single(i => i.ChapterId == 2).Id
});
Assert.Equal(2, readingList.Items.Count);
Assert.Equal(0, readingList.Items.Single(i => i.ChapterId == 3).Order);
Assert.Equal(1, readingList.Items.Single(i => i.ChapterId == 1).Order);
}
#endregion
@ -342,7 +576,6 @@ public class ReadingListServiceTests
#endregion
#region CalculateAgeRating
[Fact]
@ -412,6 +645,29 @@ public class ReadingListServiceTests
public async Task CalculateAgeRating_ShouldUpdateToMax()
{
await ResetDb();
var s = new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
},
new Chapter()
{
Number = "2",
}
}
}
}
};
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
@ -424,34 +680,14 @@ public class ReadingListServiceTests
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Test",
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>()),
Volumes = new List<Volume>()
{
new Volume()
{
Name = "0",
Chapters = new List<Chapter>()
{
new Chapter()
{
Number = "1",
},
new Chapter()
{
Number = "2",
}
}
}
}
}
s
}
},
}
});
s.Metadata.AgeRating = AgeRating.G;
await _context.SaveChangesAsync();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("majora2007", AppUserIncludes.ReadingLists);
@ -468,8 +704,579 @@ public class ReadingListServiceTests
await _unitOfWork.CommitAsync();
await _readingListService.CalculateReadingListAgeRating(readingList);
Assert.Equal(AgeRating.Unknown, readingList.AgeRating);
Assert.Equal(AgeRating.G, readingList.AgeRating);
}
#endregion
#region FormatTitle
[Fact]
public void FormatTitle_ShouldFormatCorrectly()
{
// Manga Library & Archive
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1")));
Assert.Equal("Chapter 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1", "1")));
Assert.Equal("Chapter 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1", "1", "The Title")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1", chapterTitleName: "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, chapterTitleName: "The Title")));
// Comic Library & Archive
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1")));
Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1")));
Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1", "The Title")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", chapterTitleName: "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterTitleName: "The Title")));
// Book Library & Archive
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1")));
Assert.Equal("Book 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1", "1")));
Assert.Equal("Book 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1", "1", "The Title")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1", chapterTitleName: "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, chapterTitleName: "The Title")));
// Manga Library & EPUB
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1", "1")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1", "1", "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1", chapterTitleName: "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, chapterTitleName: "The Title")));
// Book Library & EPUB
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1", "1")));
Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1", "1", "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1", chapterTitleName: "The Title")));
Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, chapterTitleName: "The Title")));
}
private static ReadingListItemDto CreateListItemDto(MangaFormat seriesFormat, LibraryType libraryType,
string volumeNumber = API.Services.Tasks.Scanner.Parser.Parser.DefaultVolume,
string chapterNumber = API.Services.Tasks.Scanner.Parser.Parser.DefaultChapter,
string chapterTitleName = "")
{
return new ReadingListItemDto()
{
SeriesFormat = seriesFormat,
LibraryType = libraryType,
VolumeNumber = volumeNumber,
ChapterNumber = chapterNumber,
ChapterTitleName = chapterTitleName
};
}
#endregion
#region CreateReadingList
private async Task CreateReadingList_SetupBaseData()
{
var fablesSeries = DbFactory.Series("Fables");
fablesSeries.Volumes.Add(new Volume()
{
Number = 1,
Name = "2002",
Chapters = new List<Chapter>()
{
EntityFactory.CreateChapter("1", false),
}
});
_context.AppUser.Add(new AppUser()
{
UserName = "majora2007",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test Lib",
Type = LibraryType.Book,
Series = new List<Series>()
{
fablesSeries,
},
},
},
});
_context.AppUser.Add(new AppUser()
{
UserName = "admin",
ReadingLists = new List<ReadingList>(),
Libraries = new List<Library>()
{
new Library()
{
Name = "Test Lib 2",
Type = LibraryType.Book,
Series = new List<Series>()
{
fablesSeries,
},
},
}
});
await _unitOfWork.CommitAsync();
}
[Fact]
public async Task CreateReadingList_ShouldCreate_WhenNoOtherListsOnUser()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists);
await _readingListService.CreateReadingListForUser(user, "Test List");
Assert.NotEmpty((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists))
.ReadingLists);
}
[Fact]
public async Task CreateReadingList_ShouldNotCreate_WhenExistingList()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists);
await _readingListService.CreateReadingListForUser(user, "Test List");
Assert.NotEmpty((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists))
.ReadingLists);
try
{
await _readingListService.CreateReadingListForUser(user, "Test List");
}
catch (Exception ex)
{
Assert.Equal("A list of this name already exists", ex.Message);
}
Assert.Single((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists))
.ReadingLists);
}
[Fact]
public async Task CreateReadingList_ShouldNotCreate_WhenPromotedListExists()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync("admin", AppUserIncludes.ReadingLists);
var list = await _readingListService.CreateReadingListForUser(user, "Test List");
await _readingListService.UpdateReadingList(list,
new UpdateReadingListDto()
{
ReadingListId = list.Id, Promoted = true, Title = list.Title, Summary = list.Summary,
CoverImageLocked = false
});
try
{
await _readingListService.CreateReadingListForUser(user, "Test List");
}
catch (Exception ex)
{
Assert.Equal("A list of this name already exists", ex.Message);
}
}
#endregion
#region UpdateReadingList
#endregion
#region DeleteReadingList
[Fact]
public async Task DeleteReadingList_ShouldDelete()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists);
await _readingListService.CreateReadingListForUser(user, "Test List");
Assert.NotEmpty((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists))
.ReadingLists);
try
{
await _readingListService.CreateReadingListForUser(user, "Test List");
}
catch (Exception ex)
{
Assert.Equal("A list of this name already exists", ex.Message);
}
Assert.Single((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists))
.ReadingLists);
await _readingListService.DeleteReadingList(1, user);
Assert.Empty((await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists))
.ReadingLists);
}
#endregion
#region UserHasReadingListAccess
// TODO: UserHasReadingListAccess tests are unavailable because I can't mock UserManager<AppUser>
public async Task UserHasReadingListAccess_ShouldWorkIfTheirList()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists);
await _readingListService.CreateReadingListForUser(user, "Test List");
var userWithList = await _readingListService.UserHasReadingListAccess(1, "majora2007");
Assert.NotNull(userWithList);
Assert.Single(userWithList.ReadingLists);
}
public async Task UserHasReadingListAccess_ShouldNotWork_IfNotTheirList()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(2, AppUserIncludes.ReadingLists);
await _readingListService.CreateReadingListForUser(user, "Test List");
var userWithList = await _readingListService.UserHasReadingListAccess(1, "majora2007");
Assert.Null(userWithList);
}
public async Task UserHasReadingListAccess_ShouldWork_IfNotTheirList_ButUserIsAdmin()
{
await ResetDb();
await CreateReadingList_SetupBaseData();
var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists);
await _readingListService.CreateReadingListForUser(user, "Test List");
//var admin = await _unitOfWork.UserRepository.GetUserByIdAsync(2, AppUserIncludes.ReadingLists);
//_userManager.When(x => x.IsInRoleAsync(user, PolicyConstants.AdminRole)).Returns((info => true), null);
//_userManager.IsInRoleAsync(admin, PolicyConstants.AdminRole).ReturnsForAnyArgs(true);
var userWithList = await _readingListService.UserHasReadingListAccess(1, "majora2007");
Assert.NotNull(userWithList);
Assert.Single(userWithList.ReadingLists);
}
#endregion
//
// #region CreateReadingListFromCBL
//
// private static CblReadingList LoadCblFromPath(string path)
// {
// var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/ReadingListService/");
//
// var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList));
// using var file = new StreamReader(Path.Join(testDirectory, path));
// var cblReadingList = (CblReadingList) reader.Deserialize(file);
// file.Close();
// return cblReadingList;
// }
//
// [Fact]
// public async Task CreateReadingListFromCBL_ShouldCreateList()
// {
// await ResetDb();
// var cblReadingList = LoadCblFromPath("Fables.cbl");
//
// // Mock up our series
// var fablesSeries = DbFactory.Series("Fables");
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
//
// fablesSeries.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2002",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// fables2Series.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2003",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// ReadingLists = new List<ReadingList>(),
// Libraries = new List<Library>()
// {
// new Library()
// {
// Name = "Test LIb",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// fablesSeries,
// fables2Series
// },
// },
// },
// });
// await _unitOfWork.CommitAsync();
//
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
//
// Assert.Equal(CblImportResult.Partial, importSummary.Success);
// Assert.NotEmpty(importSummary.Results);
//
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
//
// Assert.NotNull(createdList);
// Assert.Equal("Fables", createdList.Title);
//
// Assert.Equal(4, createdList.Items.Count);
// Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId);
// Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId);
// Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId);
// Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId);
// }
//
// [Fact]
// public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo()
// {
// await ResetDb();
// var cblReadingList = LoadCblFromPath("Fables.cbl");
//
// // Mock up our series
// var fablesSeries = DbFactory.Series("Fables");
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
//
// fablesSeries.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2002",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// fables2Series.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2003",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// ReadingLists = new List<ReadingList>(),
// Libraries = new List<Library>()
// {
// new Library()
// {
// Name = "Test LIb",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// fablesSeries,
// },
// },
// },
// });
//
// _context.Library.Add(new Library()
// {
// Name = "Test Lib 2",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// fables2Series,
// },
// });
//
// await _unitOfWork.CommitAsync();
//
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
//
// Assert.Equal(CblImportResult.Partial, importSummary.Success);
// Assert.NotEmpty(importSummary.Results);
//
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
//
// Assert.NotNull(createdList);
// Assert.Equal("Fables", createdList.Title);
//
// Assert.Equal(3, createdList.Items.Count);
// Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId);
// Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId);
// Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId);
// Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle"
// && r.Reason == CblImportReason.SeriesMissing));
// }
//
// [Fact]
// public async Task CreateReadingListFromCBL_ShouldFail_UserHasAccessToNoSeries()
// {
// await ResetDb();
// var cblReadingList = LoadCblFromPath("Fables.cbl");
//
// // Mock up our series
// var fablesSeries = DbFactory.Series("Fables");
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
//
// fablesSeries.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2002",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// fables2Series.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2003",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// ReadingLists = new List<ReadingList>(),
// Libraries = new List<Library>(),
// });
//
// _context.Library.Add(new Library()
// {
// Name = "Test Lib 2",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// fablesSeries,
// fables2Series,
// },
// });
//
// await _unitOfWork.CommitAsync();
//
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
//
// Assert.Equal(CblImportResult.Fail, importSummary.Success);
// Assert.NotEmpty(importSummary.Results);
//
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
//
// Assert.Null(createdList);
// }
//
//
// [Fact]
// public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList()
// {
// await ResetDb();
// var cblReadingList = LoadCblFromPath("Fables.cbl");
//
// // Mock up our series
// var fablesSeries = DbFactory.Series("Fables");
// var fables2Series = DbFactory.Series("Fables: The Last Castle");
//
// fablesSeries.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2002",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
// fables2Series.Volumes.Add(new Volume()
// {
// Number = 1,
// Name = "2003",
// Chapters = new List<Chapter>()
// {
// EntityFactory.CreateChapter("1", false),
// EntityFactory.CreateChapter("2", false),
// EntityFactory.CreateChapter("3", false),
//
// }
// });
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// ReadingLists = new List<ReadingList>(),
// Libraries = new List<Library>()
// {
// new Library()
// {
// Name = "Test LIb",
// Type = LibraryType.Book,
// Series = new List<Series>()
// {
// fablesSeries,
// fables2Series
// },
// },
// },
// });
//
// await _unitOfWork.CommitAsync();
//
// // Create a reading list named Fables and add 2 chapters to it
// var user = await _unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists);
// var readingList = await _readingListService.CreateReadingListForUser(user, "Fables");
// Assert.True(await _readingListService.AddChaptersToReadingList(1, new List<int>() {1, 3}, readingList));
// Assert.Equal(2, readingList.Items.Count);
//
// // Attempt to import a Cbl with same reading list name
// var importSummary = await _readingListService.CreateReadingListFromCbl(1, cblReadingList);
//
// Assert.Equal(CblImportResult.Partial, importSummary.Success);
// Assert.NotEmpty(importSummary.Results);
//
// var createdList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1);
//
// Assert.NotNull(createdList);
// Assert.Equal("Fables", createdList.Title);
//
// Assert.Equal(4, createdList.Items.Count);
// Assert.Equal(4, importSummary.SuccessfulInserts.Count);
//
// Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId);
// Assert.Equal(3, createdList.Items.First(item => item.Order == 1).ChapterId); // we inserted 3 first
// Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId);
// Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId);
// }
// #endregion
//
}

View File

@ -28,80 +28,18 @@ using Xunit;
namespace API.Tests.Services;
public class SeriesServiceTests
public class SeriesServiceTests : AbstractDbTest
{
private readonly IUnitOfWork _unitOfWork;
private readonly DbConnection _connection;
private readonly DataContext _context;
private readonly ISeriesService _seriesService;
private const string CacheDirectory = "C:/kavita/config/cache/";
private const string CoverImageDirectory = "C:/kavita/config/covers/";
private const string BackupDirectory = "C:/kavita/config/backups/";
private const string DataDirectory = "C:/data/";
public SeriesServiceTests()
public SeriesServiceTests() : base()
{
var contextOptions = new DbContextOptionsBuilder().UseSqlite(CreateInMemoryDatabase()).Options;
_connection = RelationalOptionsExtension.Extract(contextOptions).Connection;
_context = new DataContext(contextOptions);
Task.Run(SeedDb).GetAwaiter().GetResult();
var config = new MapperConfiguration(cfg => cfg.AddProfile<AutoMapperProfiles>());
var mapper = config.CreateMapper();
_unitOfWork = new UnitOfWork(_context, mapper, null);
_seriesService = new SeriesService(_unitOfWork, Substitute.For<IEventHub>(),
Substitute.For<ITaskScheduler>(), Substitute.For<ILogger<SeriesService>>());
}
#region Setup
private static DbConnection CreateInMemoryDatabase()
{
var connection = new SqliteConnection("Filename=:memory:");
connection.Open();
return connection;
}
private async Task<bool> SeedDb()
{
await _context.Database.MigrateAsync();
var filesystem = CreateFileSystem();
await Seed.SeedSettings(_context,
new DirectoryService(Substitute.For<ILogger<DirectoryService>>(), filesystem));
var setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.CacheDirectory).SingleAsync();
setting.Value = CacheDirectory;
setting = await _context.ServerSetting.Where(s => s.Key == ServerSettingKey.BackupDirectory).SingleAsync();
setting.Value = BackupDirectory;
_context.ServerSetting.Update(setting);
// var lib = new Library()
// {
// Name = "Manga", Folders = new List<FolderPath>() {new FolderPath() {Path = "C:/data/"}}
// };
//
// _context.AppUser.Add(new AppUser()
// {
// UserName = "majora2007",
// Libraries = new List<Library>()
// {
// lib
// }
// });
return await _context.SaveChangesAsync() > 0;
}
private async Task ResetDb()
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
_context.AppUserRating.RemoveRange(_context.AppUserRating.ToList());
@ -113,19 +51,6 @@ public class SeriesServiceTests
await _context.SaveChangesAsync();
}
private static MockFileSystem CreateFileSystem()
{
var fileSystem = new MockFileSystem();
fileSystem.Directory.SetCurrentDirectory("C:/kavita/");
fileSystem.AddDirectory("C:/kavita/config/");
fileSystem.AddDirectory(CacheDirectory);
fileSystem.AddDirectory(CoverImageDirectory);
fileSystem.AddDirectory(BackupDirectory);
fileSystem.AddDirectory(DataDirectory);
return fileSystem;
}
private static UpdateRelatedSeriesDto CreateRelationsDto(Series series)
{
return new UpdateRelatedSeriesDto()
@ -837,7 +762,7 @@ public class SeriesServiceTests
},
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>())
};
var g = DbFactory.Genre("Existing Genre", false);
var g = DbFactory.Genre("Existing Genre");
s.Metadata.Genres = new List<Genre>() {g};
_context.Series.Add(s);
@ -993,7 +918,7 @@ public class SeriesServiceTests
},
Metadata = DbFactory.SeriesMetadata(new List<CollectionTag>())
};
var g = DbFactory.Genre("Existing Genre", false);
var g = DbFactory.Genre("Existing Genre");
s.Metadata.Genres = new List<Genre>() {g};
s.Metadata.GenresLocked = true;
_context.Series.Add(s);
@ -1240,6 +1165,113 @@ public class SeriesServiceTests
Assert.Empty(series1.Relations.Where(s => s.TargetSeriesId == 2));
}
[Fact]
public async Task UpdateRelatedSeries_DeleteTargetSeries_ShouldSucceed()
{
await ResetDb();
_context.Library.Add(new Library()
{
AppUsers = new List<AppUser>()
{
new AppUser()
{
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Series A",
Volumes = new List<Volume>(){}
},
new Series()
{
Name = "Series B",
Volumes = new List<Volume>(){}
},
}
});
await _context.SaveChangesAsync();
var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related);
// Add relations
var addRelationDto = CreateRelationsDto(series1);
addRelationDto.Adaptations.Add(2);
await _seriesService.UpdateRelatedSeries(addRelationDto);
Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId);
_context.Series.Remove(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2));
try
{
await _context.SaveChangesAsync();
}
catch (Exception)
{
Assert.Fail("Delete of Target Series Failed");
}
// Remove relations
Assert.Empty((await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related)).Relations);
}
[Fact]
public async Task UpdateRelatedSeries_DeleteSourceSeries_ShouldSucceed()
{
await ResetDb();
_context.Library.Add(new Library()
{
AppUsers = new List<AppUser>()
{
new AppUser()
{
UserName = "majora2007"
}
},
Name = "Test LIb",
Type = LibraryType.Book,
Series = new List<Series>()
{
new Series()
{
Name = "Series A",
Volumes = new List<Volume>(){}
},
new Series()
{
Name = "Series B",
Volumes = new List<Volume>(){}
},
}
});
await _context.SaveChangesAsync();
var series1 = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1, SeriesIncludes.Related);
// Add relations
var addRelationDto = CreateRelationsDto(series1);
addRelationDto.Adaptations.Add(2);
await _seriesService.UpdateRelatedSeries(addRelationDto);
Assert.Equal(2, series1.Relations.Single(s => s.TargetSeriesId == 2).TargetSeriesId);
_context.Series.Remove(await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(1));
try
{
await _context.SaveChangesAsync();
}
catch (Exception)
{
Assert.Fail("Delete of Target Series Failed");
}
// Remove relations
Assert.Empty((await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(2, SeriesIncludes.Related)).Relations);
}
[Fact]
public async Task UpdateRelatedSeries_ShouldNotAllowDuplicates()
{
@ -1358,7 +1390,7 @@ public class SeriesServiceTests
public async Task SeriesRelation_ShouldAllowDeleteOnLibrary()
{
await ResetDb();
_context.Library.Add(new Library()
var lib = new Library()
{
AppUsers = new List<AppUser>()
{
@ -1374,20 +1406,21 @@ public class SeriesServiceTests
new Series()
{
Name = "Test Series",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Prequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Sequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
}
}
});
};
_context.Library.Add(lib);
await _context.SaveChangesAsync();
@ -1398,7 +1431,7 @@ public class SeriesServiceTests
addRelationDto.Sequels.Add(3);
await _seriesService.UpdateRelatedSeries(addRelationDto);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib.Id);
_unitOfWork.LibraryRepository.Delete(library);
try
@ -1417,7 +1450,7 @@ public class SeriesServiceTests
public async Task SeriesRelation_ShouldAllowDeleteOnLibrary_WhenSeriesCrossLibraries()
{
await ResetDb();
_context.Library.Add(new Library()
var lib1 = new Library()
{
AppUsers = new List<AppUser>()
{
@ -1457,17 +1490,17 @@ public class SeriesServiceTests
new Series()
{
Name = "Test Series Prequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Sequels",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
}
}
});
_context.Library.Add(new Library()
};
_context.Library.Add(lib1);
var lib2 = new Library()
{
AppUsers = new List<AppUser>()
{
@ -1483,20 +1516,21 @@ public class SeriesServiceTests
new Series()
{
Name = "Test Series 2",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Prequels 2",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
},
new Series()
{
Name = "Test Series Sequels 2",
Volumes = new List<Volume>(){}
Volumes = new List<Volume>() { }
}
}
});
};
_context.Library.Add(lib2);
await _context.SaveChangesAsync();
@ -1506,7 +1540,7 @@ public class SeriesServiceTests
addRelationDto.Adaptations.Add(4); // cross library link
await _seriesService.UpdateRelatedSeries(addRelationDto);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1, LibraryIncludes.Series);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(lib1.Id, LibraryIncludes.Series);
_unitOfWork.LibraryRepository.Delete(library);
try
@ -1521,5 +1555,11 @@ public class SeriesServiceTests
Assert.Null(await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(1));
}
#endregion
#region UpdateRelatedList
#endregion
}

View File

@ -0,0 +1,67 @@
<?xml version="1.0"?>
<ReadingList xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<Name>Fables</Name>
<Books>
<Book Series="Fables" Number="1" Volume="2002" Year="2002">
<Id>5bd3dd55-2a85-4325-aefa-21e9f19b12c9</Id>
</Book>
<Book Series="Fables" Number="2" Volume="2002" Year="2002">
<Id>3831761c-604a-4420-bed2-9f5ac4e94bd4</Id>
</Book>
<Book Series="Fables" Number="3" Volume="2002" Year="2002">
<Id>6353c208-b566-4cc2-b07f-96e122caae31</Id>
</Book>
<Book Series="Fables" Number="4" Volume="2002" Year="2002">
<Id>09688abb-6ec3-4e98-8acf-d622e3b210ab</Id>
</Book>
<Book Series="Fables" Number="5" Volume="2002" Year="2002">
<Id>23acefd4-1bc7-4c3c-99df-133045d1f266</Id>
</Book>
<Book Series="Fables" Number="6" Volume="2002" Year="2002">
<Id>27a5d7db-9f7e-4be1-aca6-998a1cc1488f</Id>
</Book>
<Book Series="Fables" Number="7" Volume="2002" Year="2003">
<Id>872d1218-b463-4d00-b588-a36e24e3f6d2</Id>
</Book>
<Book Series="Fables" Number="8" Volume="2002" Year="2003">
<Id>8fdbe8fe-a83c-4f23-b37a-66b214517c80</Id>
</Book>
<Book Series="Fables" Number="9" Volume="2002" Year="2003">
<Id>4759c53e-6ae7-423f-b5bf-f3310764765e</Id>
</Book>
<Book Series="Fables" Number="10" Volume="2002" Year="2003">
<Id>7d6b38cd-f83b-4762-8026-9e6edc8c4f22</Id>
</Book>
<Book Series="Fables" Number="11" Volume="2002" Year="2003">
<Id>23a48c6f-2879-4d06-9f24-a1d605292059</Id>
</Book>
<Book Series="Fables" Number="12" Volume="2002" Year="2003">
<Id>0345cf90-de98-43a9-8c4f-9b2a7f000ca6</Id>
</Book>
<Book Series="Fables" Number="13" Volume="2002" Year="2003">
<Id>7ad9aa88-2156-42bd-b8bf-a92525fbf9ee</Id>
</Book>
<Book Series="Fables" Number="14" Volume="2002" Year="2003">
<Id>c2ffb724-016b-411c-846b-412f7b003ef6</Id>
</Book>
<Book Series="Fables" Number="15" Volume="2002" Year="2003">
<Id>f3f38f3f-42e8-47e6-9b36-87d00ce48b1b</Id>
</Book>
<Book Series="Fables" Number="16" Volume="2002" Year="2003">
<Id>54523d9b-31e5-4fd2-840b-8c653a457b7b</Id>
</Book>
<Book Series="Fables" Number="17" Volume="2002" Year="2003">
<Id>01eb2417-fb46-4621-a0c3-ecf9ea3a2221</Id>
</Book>
<Book Series="Fables" Number="18" Volume="2002" Year="2003">
<Id>091e72bf-fa87-4f95-ab75-f8ed3d943828</Id>
</Book>
<Book Series="Fables: The Last Castle" Number="1" Volume="2003" Year="2003">
<Id>cad6353e-09c8-470a-b58a-0896c9b0a5d2</Id>
</Book>
<Book Series="Fables" Number="22" Volume="2002" Year="2004">
<Id>33d7e642-0cd3-48a1-a3ee-30aaf9f31edd</Id>
</Book>
</Books>
<Matchers />
</ReadingList>

View File

@ -0,0 +1,176 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions.TestingHelpers;
using System.Linq;
using System.Threading.Tasks;
using API.Entities;
using API.Entities.Enums;
using API.Helpers;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using API.Tests.Helpers;
using Microsoft.Extensions.Logging;
using NSubstitute;
using Xunit;
namespace API.Tests.Services;
public class WordCountAnalysisTests : AbstractDbTest
{
private readonly IReaderService _readerService;
private readonly string _testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Services/Test Data/BookService");
private const long WordCount = 37417;
private const long MinHoursToRead = 1;
private const long AvgHoursToRead = 2;
private const long MaxHoursToRead = 4;
public WordCountAnalysisTests() : base()
{
_readerService = new ReaderService(_unitOfWork, Substitute.For<ILogger<ReaderService>>(),
Substitute.For<IEventHub>());
}
protected override async Task ResetDb()
{
_context.Series.RemoveRange(_context.Series.ToList());
await _context.SaveChangesAsync();
}
[Fact]
public async Task ReadingTimeShouldBeNonZero()
{
await ResetDb();
var series = EntityFactory.CreateSeries("Test Series");
series.Format = MangaFormat.Epub;
var chapter = EntityFactory.CreateChapter("", false, new List<MangaFile>()
{
EntityFactory.CreateMangaFile(
Path.Join(_testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"),
MangaFormat.Epub, 0)
});
_context.Library.Add(new Library()
{
Name = "Test",
Type = LibraryType.Book,
Series = new List<Series>() {series}
});
series.Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>() {chapter})
};
await _context.SaveChangesAsync();
var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService);
await service.ScanSeries(1, 1);
Assert.Equal(WordCount, series.WordCount);
Assert.Equal(MinHoursToRead, series.MinHoursToRead);
Assert.Equal(AvgHoursToRead, series.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, series.MaxHoursToRead);
// Validate the Chapter gets updated correctly
var volume = series.Volumes.First();
Assert.Equal(WordCount, volume.WordCount);
Assert.Equal(MinHoursToRead, volume.MinHoursToRead);
Assert.Equal(AvgHoursToRead, volume.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, volume.MaxHoursToRead);
Assert.Equal(WordCount, chapter.WordCount);
Assert.Equal(MinHoursToRead, chapter.MinHoursToRead);
Assert.Equal(AvgHoursToRead, chapter.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, chapter.MaxHoursToRead);
}
[Fact]
public async Task ReadingTimeShouldIncreaseWhenNewBookAdded()
{
await ResetDb();
var series = EntityFactory.CreateSeries("Test Series");
series.Format = MangaFormat.Epub;
var chapter = EntityFactory.CreateChapter("", false, new List<MangaFile>()
{
EntityFactory.CreateMangaFile(
Path.Join(_testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"),
MangaFormat.Epub, 0)
});
_context.Library.Add(new Library()
{
Name = "Test",
Type = LibraryType.Book,
Series = new List<Series>() {series}
});
series.Volumes = new List<Volume>()
{
EntityFactory.CreateVolume("0", new List<Chapter>() {chapter})
};
await _context.SaveChangesAsync();
var cacheService = new CacheHelper(new FileService());
var service = new WordCountAnalyzerService(Substitute.For<ILogger<WordCountAnalyzerService>>(), _unitOfWork,
Substitute.For<IEventHub>(), cacheService, _readerService);
await service.ScanSeries(1, 1);
var chapter2 = EntityFactory.CreateChapter("2", false, new List<MangaFile>()
{
EntityFactory.CreateMangaFile(
Path.Join(_testDirectory, "The Golden Harpoon; Or, Lost Among the Floes A Story of the Whaling Grounds.epub"),
MangaFormat.Epub, 0)
});
series.Volumes.Add(EntityFactory.CreateVolume("1", new List<Chapter>() {chapter2}));
series.Volumes.First().Chapters.Add(chapter2);
await _unitOfWork.CommitAsync();
await service.ScanSeries(1, 1);
Assert.Equal(WordCount * 2L, series.WordCount);
Assert.Equal(MinHoursToRead * 2, series.MinHoursToRead);
Assert.Equal(AvgHoursToRead * 2, series.AvgHoursToRead);
Assert.Equal((MaxHoursToRead * 2) - 1, series.MaxHoursToRead); // This is just a rounding issue
var firstVolume = series.Volumes.ElementAt(0);
Assert.Equal(WordCount, firstVolume.WordCount);
Assert.Equal(MinHoursToRead, firstVolume.MinHoursToRead);
Assert.Equal(AvgHoursToRead, firstVolume.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, firstVolume.MaxHoursToRead);
var secondVolume = series.Volumes.ElementAt(1);
Assert.Equal(WordCount, secondVolume.WordCount);
Assert.Equal(MinHoursToRead, secondVolume.MinHoursToRead);
Assert.Equal(AvgHoursToRead, secondVolume.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, secondVolume.MaxHoursToRead);
// Validate original chapter doesn't change
Assert.Equal(WordCount, chapter.WordCount);
Assert.Equal(MinHoursToRead, chapter.MinHoursToRead);
Assert.Equal(AvgHoursToRead, chapter.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, chapter.MaxHoursToRead);
// Validate new chapter gets updated
Assert.Equal(WordCount, chapter2.WordCount);
Assert.Equal(MinHoursToRead, chapter2.MinHoursToRead);
Assert.Equal(AvgHoursToRead, chapter2.AvgHoursToRead);
Assert.Equal(MaxHoursToRead, chapter2.MaxHoursToRead);
}
}

View File

@ -5,11 +5,15 @@
<TargetFramework>net6.0</TargetFramework>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TieredPGO>true</TieredPGO>
<TieredCompilation>true</TieredCompilation>
<ApplicationIcon>../favicon.ico</ApplicationIcon>
</PropertyGroup>
<Target Name="PostBuild" AfterTargets="Build" Condition=" '$(Configuration)' == 'Debug' ">
<Exec Command="swagger tofile --output ../openapi.json bin/$(Configuration)/$(TargetFramework)/$(AssemblyName).dll v1" />
</Target>
<PropertyGroup Condition=" '$(Configuration)' == 'Release' ">
<DebugSymbols>false</DebugSymbols>
<ApplicationIcon>../favicon.ico</ApplicationIcon>
@ -17,7 +21,7 @@
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
<DocumentationFile>bin\$(Configuration)\$(AssemblyName).xml</DocumentationFile>
<NoWarn>1701;1702;1591</NoWarn>
</PropertyGroup>
@ -48,6 +52,7 @@
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="Docnet.Core" Version="2.4.0-alpha.4" />
<PackageReference Include="ExCSS" Version="4.1.0" />
<PackageReference Include="Flurl" Version="3.0.6" />
@ -90,6 +95,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Filters" Version="7.0.6" />
<PackageReference Include="System.Drawing.Common" Version="6.0.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.24.0" />
<PackageReference Include="System.IO.Abstractions" Version="17.2.3" />
@ -112,6 +118,7 @@
<None Remove="kavita.log" />
<None Remove="kavita.db" />
<None Remove="covers\**" />
<None Remove="wwwroot\**" />
</ItemGroup>
<ItemGroup>
@ -121,6 +128,7 @@
<Compile Remove="logs\**" />
<Compile Remove="temp\**" />
<Compile Remove="covers\**" />
<Compile Remove="wwwroot\**" />
</ItemGroup>
<ItemGroup>
@ -135,6 +143,7 @@
<EmbeddedResource Remove="config\logs\**" />
<EmbeddedResource Remove="config\temp\**" />
<EmbeddedResource Remove="config\stats\**" />
<EmbeddedResource Remove="wwwroot\**" />
</ItemGroup>
<ItemGroup>
@ -160,178 +169,12 @@
</Content>
</ItemGroup>
<ItemGroup>
<_ContentIncludedByDefault Remove="logs\kavita.json" />
<_ContentIncludedByDefault Remove="wwwroot\3rdpartylicenses.txt" />
<_ContentIncludedByDefault Remove="wwwroot\6.d9925ea83359bb4c7278.js" />
<_ContentIncludedByDefault Remove="wwwroot\6.d9925ea83359bb4c7278.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\7.860cdd6fd9d758e6c210.js" />
<_ContentIncludedByDefault Remove="wwwroot\7.860cdd6fd9d758e6c210.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\8.028f6737a2f0621d40c7.js" />
<_ContentIncludedByDefault Remove="wwwroot\8.028f6737a2f0621d40c7.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\EBGarmond\EBGaramond-Italic-VariableFont_wght.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\EBGarmond\EBGaramond-VariableFont_wght.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\EBGarmond\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Black.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-BlackItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-BoldItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-ExtraBold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-ExtraBoldItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-ExtraLight.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-ExtraLightItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Italic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Light.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-LightItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Medium.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-MediumItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-SemiBold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-SemiBoldItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-Thin.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\FiraSans-ThinItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Fira_Sans\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-Black.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-BlackItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-BoldItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-Italic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-Light.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-LightItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-Thin.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\Lato-ThinItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Lato\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Baskerville\LibreBaskerville-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Baskerville\LibreBaskerville-Italic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Baskerville\LibreBaskerville-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Baskerville\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Caslon\LibreCaslonText-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Caslon\LibreCaslonText-Italic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Caslon\LibreCaslonText-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Libre_Caslon\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-Black.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-BlackItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-BoldItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-Italic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-Light.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-LightItalic.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\Merriweather-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Merriweather\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Nanum_Gothic\NanumGothic-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Nanum_Gothic\NanumGothic-ExtraBold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Nanum_Gothic\NanumGothic-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Nanum_Gothic\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\Oswald-VariableFont_wght.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\README.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\static\Oswald-Bold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\static\Oswald-ExtraLight.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\static\Oswald-Light.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\static\Oswald-Medium.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\static\Oswald-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Oswald\static\Oswald-SemiBold.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\RocknRoll_One\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\RocknRoll_One\RocknRollOne-Regular.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\error-placeholder-min.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\error-placeholder.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\error-placeholder2-min.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\error-placeholder2.dark-min.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\error-placeholder2.dark.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\error-placeholder2.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\image-placeholder-min.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\image-placeholder.dark-min.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\image-placeholder.dark.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\image-placeholder.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\preset-light.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\themes\dark.scss" />
<_ContentIncludedByDefault Remove="wwwroot\common.ad975892146299f80adb.js" />
<_ContentIncludedByDefault Remove="wwwroot\common.ad975892146299f80adb.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\EBGaramond-VariableFont_wght.2a1da2dbe7a28d63f8cb.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\fa-brands-400.0fea24969112a781acd2.eot" />
<_ContentIncludedByDefault Remove="wwwroot\fa-brands-400.c967a94cfbe2b06627ff.woff2" />
<_ContentIncludedByDefault Remove="wwwroot\fa-brands-400.dc2cbadd690e1d4b2c9c.woff" />
<_ContentIncludedByDefault Remove="wwwroot\fa-brands-400.e33e2cf6e02cac2ccb77.svg" />
<_ContentIncludedByDefault Remove="wwwroot\fa-brands-400.ec82f282c7f54b637098.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\fa-regular-400.06b9d19ced8d17f3d5cb.svg" />
<_ContentIncludedByDefault Remove="wwwroot\fa-regular-400.08f9891a6f44d9546678.eot" />
<_ContentIncludedByDefault Remove="wwwroot\fa-regular-400.1008b5226941c24f4468.woff2" />
<_ContentIncludedByDefault Remove="wwwroot\fa-regular-400.1069ea55beaa01060302.woff" />
<_ContentIncludedByDefault Remove="wwwroot\fa-regular-400.1495f578452eb676f730.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\fa-solid-900.10ecefc282f2761808bf.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\fa-solid-900.371dbce0dd46bd4d2033.svg" />
<_ContentIncludedByDefault Remove="wwwroot\fa-solid-900.3a24a60e7f9c6574864a.eot" />
<_ContentIncludedByDefault Remove="wwwroot\fa-solid-900.3ceb50e7bcafb577367c.woff2" />
<_ContentIncludedByDefault Remove="wwwroot\fa-solid-900.46fdbd2d897f8824e63c.woff" />
<_ContentIncludedByDefault Remove="wwwroot\favicon.ico" />
<_ContentIncludedByDefault Remove="wwwroot\FiraSans-Regular.1c0bf0728b51cb9f2ddc.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\index.html" />
<_ContentIncludedByDefault Remove="wwwroot\Lato-Regular.9919edff6283018571ad.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\LibreBaskerville-Regular.a27f99ca45522bb3d56d.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\main.44f5c0973044295d8be0.js" />
<_ContentIncludedByDefault Remove="wwwroot\main.44f5c0973044295d8be0.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\Merriweather-Regular.55c73e48e04ec926ebfe.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\NanumGothic-Regular.6c84540de7730f833d6c.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\polyfills.348e08e9d0e910a15938.js" />
<_ContentIncludedByDefault Remove="wwwroot\polyfills.348e08e9d0e910a15938.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\RocknRollOne-Regular.c75da4712d1e65ed1f69.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\runtime.ea545c6916f85411478f.js" />
<_ContentIncludedByDefault Remove="wwwroot\runtime.ea545c6916f85411478f.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\styles.4bd902bb3037f36f2c64.css" />
<_ContentIncludedByDefault Remove="wwwroot\styles.4bd902bb3037f36f2c64.css.map" />
<_ContentIncludedByDefault Remove="wwwroot\vendor.6b2a0912ae80e6fd297f.js" />
<_ContentIncludedByDefault Remove="wwwroot\vendor.6b2a0912ae80e6fd297f.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\10.b727db78581442412e9a.js" />
<_ContentIncludedByDefault Remove="wwwroot\10.b727db78581442412e9a.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\2.fcc031071e80d6837012.js" />
<_ContentIncludedByDefault Remove="wwwroot\2.fcc031071e80d6837012.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\7.c30da7d2e809fa05d1e3.js" />
<_ContentIncludedByDefault Remove="wwwroot\7.c30da7d2e809fa05d1e3.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\8.d4c77a90c95e9861656a.js" />
<_ContentIncludedByDefault Remove="wwwroot\8.d4c77a90c95e9861656a.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\9.489b177dd1a6beeb35ad.js" />
<_ContentIncludedByDefault Remove="wwwroot\9.489b177dd1a6beeb35ad.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Spartan\OFL.txt" />
<_ContentIncludedByDefault Remove="wwwroot\assets\fonts\Spartan\Spartan-VariableFont_wght.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\android-chrome-192x192.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\android-chrome-256x256.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\apple-touch-icon.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\browserconfig.xml" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\favicon-16x16.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\favicon-32x32.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\favicon.ico" />
<_ContentIncludedByDefault Remove="wwwroot\assets\icons\mstile-150x150.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\image-reset-cover-min.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\image-reset-cover.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\kavita-book-cropped.png" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\login-bg.jpg" />
<_ContentIncludedByDefault Remove="wwwroot\assets\images\logo.png" />
<_ContentIncludedByDefault Remove="wwwroot\common.fbf71de364f5a1f37413.js" />
<_ContentIncludedByDefault Remove="wwwroot\common.fbf71de364f5a1f37413.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\login-bg.8860e6ff9d2a3598539c.jpg" />
<_ContentIncludedByDefault Remove="wwwroot\main.a3a1e647a39145accff3.js" />
<_ContentIncludedByDefault Remove="wwwroot\main.a3a1e647a39145accff3.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\polyfills.3dda3bf3d087e5d131ba.js" />
<_ContentIncludedByDefault Remove="wwwroot\polyfills.3dda3bf3d087e5d131ba.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\runtime.b9818dfc90f418b3f0a7.js" />
<_ContentIncludedByDefault Remove="wwwroot\runtime.b9818dfc90f418b3f0a7.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\scripts.7d1c78b2763c483bb699.js" />
<_ContentIncludedByDefault Remove="wwwroot\scripts.7d1c78b2763c483bb699.js.map" />
<_ContentIncludedByDefault Remove="wwwroot\site.webmanifest" />
<_ContentIncludedByDefault Remove="wwwroot\Spartan-VariableFont_wght.0427aac0d980a12ae8ba.ttf" />
<_ContentIncludedByDefault Remove="wwwroot\styles.85a58cb3e4a4b1add864.css" />
<_ContentIncludedByDefault Remove="wwwroot\styles.85a58cb3e4a4b1add864.css.map" />
<_ContentIncludedByDefault Remove="wwwroot\vendor.54bf44a9aa720ff8881d.js" />
<_ContentIncludedByDefault Remove="wwwroot\vendor.54bf44a9aa720ff8881d.js.map" />
</ItemGroup>
<ItemGroup>
<Reference Include="System.Drawing.Common" />
</ItemGroup>
<ItemGroup>
<Folder Include="config\themes" />
</ItemGroup>
<ItemGroup>
<_DeploymentManifestIconFile Remove="favicon.ico" />
</ItemGroup>
</Project>

View File

@ -62,4 +62,5 @@ public class SortComparerZeroLast : IComparer<double>
return x.CompareTo(y);
}
public static readonly SortComparerZeroLast Default = new SortComparerZeroLast();
}

View File

@ -0,0 +1,18 @@
namespace API.Constants;
public static class ResponseCacheProfiles
{
public const string Images = "Images";
public const string Hour = "Hour";
public const string TenMinute = "10Minute";
public const string FiveMinute = "5Minute";
/// <summary>
/// 6 hour long cache as underlying API is expensive
/// </summary>
public const string Statistics = "Statistics";
/// <summary>
/// Instant is a very quick cache, because we can't bust based on the query params, but rather body
/// </summary>
public const string Instant = "Instant";
public const string Month = "Month";
}

View File

@ -41,7 +41,6 @@ public class AccountController : BaseApiController
private readonly IMapper _mapper;
private readonly IAccountService _accountService;
private readonly IEmailService _emailService;
private readonly IHostEnvironment _environment;
private readonly IEventHub _eventHub;
/// <inheritdoc />
@ -50,8 +49,7 @@ public class AccountController : BaseApiController
ITokenService tokenService, IUnitOfWork unitOfWork,
ILogger<AccountController> logger,
IMapper mapper, IAccountService accountService,
IEmailService emailService, IHostEnvironment environment,
IEventHub eventHub)
IEmailService emailService, IEventHub eventHub)
{
_userManager = userManager;
_signInManager = signInManager;
@ -61,7 +59,6 @@ public class AccountController : BaseApiController
_mapper = mapper;
_accountService = accountService;
_emailService = emailService;
_environment = environment;
_eventHub = eventHub;
}
@ -202,7 +199,7 @@ public class AccountController : BaseApiController
}
// Update LastActive on account
user.LastActive = DateTime.Now;
user.UpdateLastActive();
user.UserPreferences ??= new AppUserPreferences
{
Theme = await _unitOfWork.SiteThemeRepository.GetDefaultTheme()
@ -247,7 +244,6 @@ public class AccountController : BaseApiController
[HttpGet("roles")]
public ActionResult<IList<string>> GetRoles()
{
// TODO: This should be moved to ServerController
return typeof(PolicyConstants)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(string))
@ -290,7 +286,15 @@ public class AccountController : BaseApiController
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user == null) return Unauthorized("You do not have permission");
if (dto == null || string.IsNullOrEmpty(dto.Email)) return BadRequest("Invalid payload");
if (dto == null || string.IsNullOrEmpty(dto.Email) || string.IsNullOrEmpty(dto.Password)) return BadRequest("Invalid payload");
// Validate this user's password
if (! await _userManager.CheckPasswordAsync(user, dto.Password))
{
_logger.LogCritical("A user tried to change {UserName}'s email, but password didn't validate", user.UserName);
return BadRequest("You do not have permission");
}
// Validate no other users exist with this email
if (user.Email.Equals(dto.Email)) return Ok("Nothing to do");
@ -317,10 +321,11 @@ public class AccountController : BaseApiController
// Send a confirmation email
try
{
var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email-update", dto.Email);
var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email-update", dto.Email);
_logger.LogCritical("[Update Email]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var accessible = await _emailService.CheckIfAccessible(host);
var accessible = await _accountService.CheckIfAccessible(Request);
if (accessible)
{
try
@ -488,7 +493,7 @@ public class AccountController : BaseApiController
if (string.IsNullOrEmpty(user.ConfirmationToken))
return BadRequest("Manual setup is unable to be completed. Please cancel and recreate the invite.");
return GenerateEmailLink(user.ConfirmationToken, "confirm-email", user.Email, withBaseUrl);
return await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", user.Email, withBaseUrl);
}
@ -596,11 +601,10 @@ public class AccountController : BaseApiController
try
{
var emailLink = GenerateEmailLink(user.ConfirmationToken, "confirm-email", dto.Email);
var emailLink = await _accountService.GenerateEmailLink(Request, user.ConfirmationToken, "confirm-email", dto.Email);
_logger.LogCritical("[Invite User]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
_logger.LogCritical("[Invite User]: Token {UserName}: {Token}", user.UserName, user.ConfirmationToken);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
var accessible = await _emailService.CheckIfAccessible(host);
var accessible = await _accountService.CheckIfAccessible(Request);
if (accessible)
{
try
@ -788,10 +792,9 @@ public class AccountController : BaseApiController
return BadRequest("You do not have an email on account or it has not been confirmed");
var token = await _userManager.GeneratePasswordResetTokenAsync(user);
var emailLink = GenerateEmailLink(token, "confirm-reset-password", user.Email);
var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-reset-password", user.Email);
_logger.LogCritical("[Forgot Password]: Email Link for {UserName}: {Link}", user.UserName, emailLink);
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
if (await _emailService.CheckIfAccessible(host))
if (await _accountService.CheckIfAccessible(Request))
{
await _emailService.SendPasswordResetEmail(new PasswordResetEmailDto()
{
@ -844,6 +847,11 @@ public class AccountController : BaseApiController
};
}
/// <summary>
/// Resend an invite to a user already invited
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
[HttpPost("resend-confirmation-email")]
public async Task<ActionResult<string>> ResendConfirmationSendEmail([FromQuery] int userId)
{
@ -856,26 +864,30 @@ public class AccountController : BaseApiController
if (user.EmailConfirmed) return BadRequest("User already confirmed");
var token = await _userManager.GenerateEmailConfirmationTokenAsync(user);
var emailLink = GenerateEmailLink(token, "confirm-email", user.Email);
var emailLink = await _accountService.GenerateEmailLink(Request, token, "confirm-email", user.Email);
_logger.LogCritical("[Email Migration]: Email Link: {Link}", emailLink);
_logger.LogCritical("[Email Migration]: Token {UserName}: {Token}", user.UserName, token);
await _emailService.SendMigrationEmail(new EmailMigrationDto()
if (await _accountService.CheckIfAccessible(Request))
{
EmailAddress = user.Email,
Username = user.UserName,
ServerConfirmationLink = emailLink,
InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value
});
try
{
await _emailService.SendMigrationEmail(new EmailMigrationDto()
{
EmailAddress = user.Email,
Username = user.UserName,
ServerConfirmationLink = emailLink,
InstallId = (await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.InstallId)).Value
});
}
catch (Exception ex)
{
_logger.LogError(ex, "There was an issue resending invite email");
return BadRequest("There was an issue resending invite email");
}
return Ok(emailLink);
}
return Ok(emailLink);
}
private string GenerateEmailLink(string token, string routePart, string email, bool withHost = true)
{
var host = _environment.IsDevelopment() ? "localhost:4200" : Request.Host.ToString();
if (withHost) return $"{Request.Scheme}://{host}{Request.PathBase}/registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}";
return $"registration/{routePart}?token={HttpUtility.UrlEncode(token)}&email={HttpUtility.UrlEncode(email)}";
return Ok("The server is not accessible externally");
}
/// <summary>

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@ -8,7 +7,6 @@ using API.DTOs.Reader;
using API.Entities.Enums;
using API.Services;
using Kavita.Common;
using HtmlAgilityPack;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using VersOne.Epub;
@ -97,7 +95,7 @@ public class BookController : BaseApiController
var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(chapterId);
using var book = await EpubReader.OpenBookAsync(chapter.Files.ElementAt(0).FilePath, BookService.BookReaderOptions);
var key = BookService.CleanContentKeys(file);
var key = BookService.CoalesceKeyForAnyFile(book, file);
if (!book.Content.AllFiles.ContainsKey(key)) return BadRequest("File was not found in book");
var bookFile = book.Content.AllFiles[key];

View File

@ -6,10 +6,12 @@ using API.Data;
using API.DTOs.CollectionTags;
using API.Entities.Metadata;
using API.Extensions;
using API.Services;
using API.Services.Tasks.Metadata;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
namespace API.Controllers;
@ -19,13 +21,13 @@ namespace API.Controllers;
public class CollectionController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly ICollectionTagService _collectionService;
/// <inheritdoc />
public CollectionController(IUnitOfWork unitOfWork, IEventHub eventHub)
public CollectionController(IUnitOfWork unitOfWork, ICollectionTagService collectionService)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_collectionService = collectionService;
}
/// <summary>
@ -55,12 +57,23 @@ public class CollectionController : BaseApiController
[HttpGet("search")]
public async Task<IEnumerable<CollectionTagDto>> SearchTags(string queryString)
{
queryString ??= "";
queryString ??= string.Empty;
queryString = queryString.Replace(@"%", string.Empty);
if (queryString.Length == 0) return await GetAllTags();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, user.Id);
return await _unitOfWork.CollectionTagRepository.SearchTagDtosAsync(queryString, User.GetUserId());
}
/// <summary>
/// Checks if a collection exists with the name
/// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task<ActionResult<bool>> DoesNameExists(string name)
{
return Ok(await _collectionService.TagExistsByName(name));
}
/// <summary>
@ -71,26 +84,15 @@ public class CollectionController : BaseApiController
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateTagPromotion(CollectionTagDto updatedTag)
public async Task<ActionResult> UpdateTag(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.Trim();
existingTag.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(updatedTag.Title).ToUpper();
existingTag.Summary = updatedTag.Summary.Trim();
if (_unitOfWork.HasChanges())
try
{
if (await _unitOfWork.CommitAsync())
{
return Ok("Tag updated successfully");
}
if (await _collectionService.UpdateTag(updatedTag)) return Ok("Tag updated successfully");
}
else
catch (KavitaException ex)
{
return Ok("Tag updated successfully");
return BadRequest(ex.Message);
}
return BadRequest("Something went wrong, please try again");
@ -105,29 +107,11 @@ public class CollectionController : BaseApiController
[HttpPost("update-for-series")]
public async Task<ActionResult> AddToMultipleSeries(CollectionTagBulkAddDto dto)
{
var tag = await _unitOfWork.CollectionTagRepository.GetFullTagAsync(dto.CollectionTagId);
if (tag == null)
{
tag = DbFactory.CollectionTag(0, dto.CollectionTagTitle, String.Empty, false);
_unitOfWork.CollectionTagRepository.Add(tag);
}
// Create a new tag and save
var tag = await _collectionService.GetTagOrCreate(dto.CollectionTagId, dto.CollectionTagTitle);
if (await _collectionService.AddTagToSeries(tag, dto.SeriesIds)) return Ok();
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIdsAsync(dto.SeriesIds);
foreach (var metadata in seriesMetadatas)
{
if (!metadata.CollectionTags.Any(t => t.Title.Equals(tag.Title, StringComparison.InvariantCulture)))
{
metadata.CollectionTags.Add(tag);
_unitOfWork.SeriesMetadataRepository.Update(metadata);
}
}
if (!_unitOfWork.HasChanges()) return Ok();
if (await _unitOfWork.CommitAsync())
{
return Ok();
}
return BadRequest("There was an issue updating series with collection tag");
}
@ -138,7 +122,7 @@ public class CollectionController : BaseApiController
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update-series")]
public async Task<ActionResult> UpdateSeriesForTag(UpdateSeriesForTagDto updateSeriesForTagDto)
public async Task<ActionResult> RemoveTagFromMultipleSeries(UpdateSeriesForTagDto updateSeriesForTagDto)
{
try
{
@ -146,41 +130,8 @@ public class CollectionController : BaseApiController
if (tag == null) return BadRequest("Not a valid Tag");
tag.SeriesMetadatas ??= new List<SeriesMetadata>();
// Check if Tag has updated (Summary)
if (tag.Summary == null || !tag.Summary.Equals(updateSeriesForTagDto.Tag.Summary))
{
tag.Summary = updateSeriesForTagDto.Tag.Summary;
_unitOfWork.CollectionTagRepository.Update(tag);
}
tag.CoverImageLocked = updateSeriesForTagDto.Tag.CoverImageLocked;
if (!updateSeriesForTagDto.Tag.CoverImageLocked)
{
tag.CoverImageLocked = false;
tag.CoverImage = string.Empty;
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(tag.Id, MessageFactoryEntityTypes.CollectionTag), false);
_unitOfWork.CollectionTagRepository.Update(tag);
}
foreach (var seriesIdToRemove in updateSeriesForTagDto.SeriesIdsToRemove)
{
tag.SeriesMetadatas.Remove(tag.SeriesMetadatas.Single(sm => sm.SeriesId == seriesIdToRemove));
}
if (tag.SeriesMetadatas.Count == 0)
{
_unitOfWork.CollectionTagRepository.Remove(tag);
}
if (!_unitOfWork.HasChanges()) return Ok("No updates");
if (await _unitOfWork.CommitAsync())
{
if (await _collectionService.RemoveTagFromSeries(tag, updateSeriesForTagDto.SeriesIdsToRemove))
return Ok("Tag updated");
}
}
catch (Exception)
{

View File

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Downloads;
using API.Entities;
@ -12,7 +11,6 @@ using API.Services;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@ -203,19 +201,20 @@ public class DownloadController : BaseApiController
if (!downloadBookmarkDto.Bookmarks.Any()) return BadRequest("Bookmarks cannot be empty");
// We know that all bookmarks will be for one single seriesId
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
var username = User.GetUsername();
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(downloadBookmarkDto.Bookmarks.First().SeriesId);
var files = await _bookmarkService.GetBookmarkFilesById(downloadBookmarkDto.Bookmarks.Select(b => b.Id));
var filename = $"{series.Name} - Bookmarks.zip";
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 0F));
MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 0F));
var seriesIds = string.Join("_", downloadBookmarkDto.Bookmarks.Select(b => b.SeriesId).Distinct());
var filePath = _archiveService.CreateZipForDownload(files,
$"download_{user.Id}_{seriesIds}_bookmarks");
$"download_{userId}_{seriesIds}_bookmarks");
await _eventHub.SendMessageAsync(MessageFactory.NotificationProgress,
MessageFactory.DownloadProgressEvent(User.GetUsername(), Path.GetFileNameWithoutExtension(filename), 1F));
MessageFactory.DownloadProgressEvent(username, Path.GetFileNameWithoutExtension(filename), 1F));
return PhysicalFile(filePath, DefaultContentType, filename, true);

View File

@ -1,5 +1,6 @@
using System.IO;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Entities.Enums;
using API.Extensions;
@ -31,12 +32,28 @@ public class ImageController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("chapter-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId"})]
public async Task<ActionResult> GetChapterCoverImage(int chapterId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ChapterRepository.GetChapterCoverImageAsync(chapterId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
/// <summary>
/// Returns cover image for Library
/// </summary>
/// <param name="libraryId"></param>
/// <returns></returns>
[HttpGet("library-cover")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"libraryId"})]
public async Task<ActionResult> GetLibraryCoverImage(int libraryId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.LibraryRepository.GetLibraryCoverImageAsync(libraryId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
@ -47,12 +64,12 @@ public class ImageController : BaseApiController
/// <param name="volumeId"></param>
/// <returns></returns>
[HttpGet("volume-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"volumeId"})]
public async Task<ActionResult> GetVolumeCoverImage(int volumeId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.VolumeRepository.GetVolumeCoverImageAsync(volumeId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
@ -62,13 +79,13 @@ public class ImageController : BaseApiController
/// </summary>
/// <param name="seriesId">Id of Series</param>
/// <returns></returns>
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"seriesId"})]
[HttpGet("series-cover")]
public async Task<ActionResult> GetSeriesCoverImage(int seriesId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.SeriesRepository.GetSeriesCoverImageAsync(seriesId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
Response.AddCacheHeader(path);
@ -81,12 +98,12 @@ public class ImageController : BaseApiController
/// <param name="collectionTagId"></param>
/// <returns></returns>
[HttpGet("collection-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"collectionTagId"})]
public async Task<ActionResult> GetCollectionCoverImage(int collectionTagId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.CollectionTagRepository.GetCoverImageAsync(collectionTagId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
@ -97,12 +114,12 @@ public class ImageController : BaseApiController
/// <param name="readingListId"></param>
/// <returns></returns>
[HttpGet("readinglist-cover")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"readingListId"})]
public async Task<ActionResult> GetReadingListCoverImage(int readingListId)
{
var path = Path.Join(_directoryService.CoverImageDirectory, await _unitOfWork.ReadingListRepository.GetCoverImageAsync(readingListId));
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"No cover image");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}
@ -116,7 +133,7 @@ public class ImageController : BaseApiController
/// <param name="apiKey">API Key for user. Needed to authenticate request</param>
/// <returns></returns>
[HttpGet("bookmark")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"chapterId", "pageNum", "apiKey"})]
public async Task<ActionResult> GetBookmarkImage(int chapterId, int pageNum, string apiKey)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByApiKeyAsync(apiKey);
@ -126,7 +143,7 @@ public class ImageController : BaseApiController
var bookmarkDirectory =
(await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.BookmarkDirectory)).Value;
var file = new FileInfo(Path.Join(bookmarkDirectory, bookmark.FileName));
var format = Path.GetExtension(file.FullName).Replace(".", "");
var format = Path.GetExtension(file.FullName).Replace(".", string.Empty);
return PhysicalFile(file.FullName, "image/" + format, Path.GetFileName(file.FullName));
}
@ -138,14 +155,14 @@ public class ImageController : BaseApiController
/// <returns></returns>
[Authorize(Policy="RequireAdminRole")]
[HttpGet("cover-upload")]
[ResponseCache(CacheProfileName = "Images")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Images, VaryByQueryKeys = new []{"filename"})]
public ActionResult GetCoverUploadImage(string filename)
{
if (filename.Contains("..")) return BadRequest("Invalid Filename");
var path = Path.Join(_directoryService.TempDirectory, filename);
if (string.IsNullOrEmpty(path) || !_directoryService.FileSystem.File.Exists(path)) return BadRequest($"File does not exist");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", "");
var format = _directoryService.FileSystem.Path.GetExtension(path).Replace(".", string.Empty);
return PhysicalFile(path, "image/" + format, _directoryService.FileSystem.Path.GetFileName(path));
}

View File

@ -247,11 +247,9 @@ public class LibraryController : BaseApiController
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
if (TaskScheduler.HasScanTaskRunningForLibrary(libraryId))
{
// TODO: Figure out how to cancel a job
_logger.LogInformation("User is attempting to delete a library while a scan is in progress");
return BadRequest(
"You cannot delete a library while a scan is in progress. Please wait for scan to continue then try to delete");
"You cannot delete a library while a scan is in progress. Please wait for scan to complete or restart Kavita then try to delete");
}
// Due to a bad schema that I can't figure out how to fix, we need to erase all RelatedSeries before we delete the library
@ -295,35 +293,65 @@ public class LibraryController : BaseApiController
}
}
/// <summary>
/// Checks if the library name exists or not
/// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task<ActionResult<bool>> IsLibraryNameValid(string name)
{
var trimmed = name.Trim();
if (string.IsNullOrEmpty(trimmed)) return Ok(true);
return Ok(await _unitOfWork.LibraryRepository.LibraryExists(trimmed));
}
/// <summary>
/// Updates an existing Library with new name, folders, and/or type.
/// </summary>
/// <remarks>Any folder or type change will invoke a scan.</remarks>
/// <param name="libraryForUserDto"></param>
/// <param name="dto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("update")]
public async Task<ActionResult> UpdateLibrary(UpdateLibraryDto libraryForUserDto)
public async Task<ActionResult> UpdateLibrary(UpdateLibraryDto dto)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryForUserDto.Id, LibraryIncludes.Folders);
var newName = dto.Name.Trim();
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(dto.Id, LibraryIncludes.Folders);
if (await _unitOfWork.LibraryRepository.LibraryExists(newName) && !library.Name.Equals(newName))
return BadRequest("Library name already exists");
var originalFolders = library.Folders.Select(x => x.Path).ToList();
library.Name = libraryForUserDto.Name;
library.Folders = libraryForUserDto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
library.Name = newName;
library.Folders = dto.Folders.Select(s => new FolderPath() {Path = s}).ToList();
var typeUpdate = library.Type != libraryForUserDto.Type;
library.Type = libraryForUserDto.Type;
var typeUpdate = library.Type != dto.Type;
var folderWatchingUpdate = library.FolderWatching != dto.FolderWatching;
library.Type = dto.Type;
library.FolderWatching = dto.FolderWatching;
library.IncludeInDashboard = dto.IncludeInDashboard;
library.IncludeInRecommended = dto.IncludeInRecommended;
library.IncludeInSearch = dto.IncludeInSearch;
library.ManageCollections = dto.CreateCollections;
_unitOfWork.LibraryRepository.Update(library);
if (!await _unitOfWork.CommitAsync()) return BadRequest("There was a critical issue updating the library.");
if (originalFolders.Count != libraryForUserDto.Folders.Count() || typeUpdate)
if (originalFolders.Count != dto.Folders.Count() || typeUpdate)
{
await _libraryWatcher.RestartWatching();
_taskScheduler.ScanLibrary(library.Id);
}
if (folderWatchingUpdate)
{
await _libraryWatcher.RestartWatching();
}
await _eventHub.SendMessageAsync(MessageFactory.LibraryModified,
MessageFactory.LibraryModifiedEvent(library.Id, "update"), false);
return Ok();
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs;
using API.DTOs.Filtering;
@ -84,7 +85,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all ratings</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
[HttpGet("age-ratings")]
public async Task<ActionResult<IList<AgeRatingDto>>> GetAllAgeRatings(string? libraryIds)
{
@ -107,7 +108,7 @@ public class MetadataController : BaseApiController
/// <param name="libraryIds">String separated libraryIds or null for all publication status</param>
/// <remarks>This API is cached for 1 hour, varying by libraryIds</remarks>
/// <returns></returns>
[ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"libraryIds"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"libraryIds"})]
[HttpGet("publication-status")]
public ActionResult<IList<AgeRatingDto>> GetAllPublicationStatus(string? libraryIds)
{

View File

@ -253,6 +253,7 @@ public class OpdsController : BaseApiController
PageNumber = pageNumber,
PageSize = 20
});
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
var feed = CreateFeed(tag.Title + " Collection", $"{apiKey}/collections/{collectionId}", apiKey);
SetFeedId(feed, $"collections-{collectionId}");
@ -260,7 +261,7 @@ public class OpdsController : BaseApiController
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey));
}
@ -322,7 +323,7 @@ public class OpdsController : BaseApiController
var items = (await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId)).ToList();
foreach (var item in items)
{
feed.Entries.Add(CreateChapter(apiKey, $"{item.SeriesName} Chapter {item.ChapterNumber}", item.ChapterId, item.VolumeId, item.SeriesId));
feed.Entries.Add(CreateChapter(apiKey, $"{item.Order} - {item.SeriesName}: {item.Title}", string.Empty, item.ChapterId, item.VolumeId, item.SeriesId));
}
return CreateXmlResult(SerializeXml(feed));
}
@ -347,6 +348,7 @@ public class OpdsController : BaseApiController
PageNumber = pageNumber,
PageSize = 20
}, _filterDto);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(series.Select(s => s.Id));
var feed = CreateFeed(library.Name, $"{apiKey}/libraries/{libraryId}", apiKey);
SetFeedId(feed, $"library-{library.Name}");
@ -354,7 +356,7 @@ public class OpdsController : BaseApiController
foreach (var seriesDto in series)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey));
}
return CreateXmlResult(SerializeXml(feed));
@ -372,6 +374,7 @@ public class OpdsController : BaseApiController
PageNumber = pageNumber,
PageSize = 20
}, _filterDto);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(recentlyAdded.Select(s => s.Id));
var feed = CreateFeed("Recently Added", $"{apiKey}/recently-added", apiKey);
SetFeedId(feed, "recently-added");
@ -379,7 +382,7 @@ public class OpdsController : BaseApiController
foreach (var seriesDto in recentlyAdded)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey));
}
return CreateXmlResult(SerializeXml(feed));
@ -397,6 +400,7 @@ public class OpdsController : BaseApiController
PageNumber = pageNumber,
};
var pagedList = await _unitOfWork.SeriesRepository.GetOnDeck(userId, 0, userParams, _filterDto);
var seriesMetadatas = await _unitOfWork.SeriesRepository.GetSeriesMetadataForIds(pagedList.Select(s => s.Id));
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
@ -406,7 +410,7 @@ public class OpdsController : BaseApiController
foreach (var seriesDto in pagedList)
{
feed.Entries.Add(CreateSeries(seriesDto, apiKey));
feed.Entries.Add(CreateSeries(seriesDto, seriesMetadatas.First(s => s.SeriesId == seriesDto.Id), apiKey));
}
return CreateXmlResult(SerializeXml(feed));
@ -425,7 +429,7 @@ public class OpdsController : BaseApiController
{
return BadRequest("You must pass a query parameter");
}
query = query.Replace(@"%", "");
query = query.Replace(@"%", string.Empty);
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(userId)).ToList();
@ -527,7 +531,7 @@ public class OpdsController : BaseApiController
if (volume.Chapters.Count == 1)
{
var firstChapter = volume.Chapters.First();
var chapter = CreateChapter(apiKey, volume.Name, firstChapter.Id, volume.Id, seriesId);
var chapter = CreateChapter(apiKey, volume.Name, firstChapter.Summary, firstChapter.Id, volume.Id, seriesId);
chapter.Id = firstChapter.Id.ToString();
feed.Entries.Add(chapter);
}
@ -540,12 +544,12 @@ public class OpdsController : BaseApiController
foreach (var storylineChapter in seriesDetail.StorylineChapters.Where(c => !c.IsSpecial))
{
feed.Entries.Add(CreateChapter(apiKey, storylineChapter.Title, storylineChapter.Id, storylineChapter.VolumeId, seriesId));
feed.Entries.Add(CreateChapter(apiKey, storylineChapter.Title, storylineChapter.Summary, storylineChapter.Id, storylineChapter.VolumeId, seriesId));
}
foreach (var special in seriesDetail.Specials)
{
feed.Entries.Add(CreateChapter(apiKey, special.Title, special.Id, special.VolumeId, seriesId));
feed.Entries.Add(CreateChapter(apiKey, special.Title, special.Summary, special.Id, special.VolumeId, seriesId));
}
return CreateXmlResult(SerializeXml(feed));
@ -679,13 +683,23 @@ public class OpdsController : BaseApiController
feed.StartIndex = (Math.Max(list.CurrentPage - 1, 0) * list.PageSize) + 1;
}
private static FeedEntry CreateSeries(SeriesDto seriesDto, string apiKey)
private static FeedEntry CreateSeries(SeriesDto seriesDto, SeriesMetadataDto metadata, string apiKey)
{
return new FeedEntry()
{
Id = seriesDto.Id.ToString(),
Title = $"{seriesDto.Name} ({seriesDto.Format})",
Summary = seriesDto.Summary,
Authors = metadata.Writers.Select(p => new FeedAuthor()
{
Name = p.Name,
Uri = "http://opds-spec.org/author"
}).ToList(),
Categories = metadata.Genres.Select(g => new FeedCategory()
{
Label = g.Title,
Term = string.Empty
}).ToList(),
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation, Prefix + $"{apiKey}/series/{seriesDto.Id}"),
@ -716,6 +730,7 @@ public class OpdsController : BaseApiController
{
Id = volumeDto.Id.ToString(),
Title = volumeDto.Name,
Summary = volumeDto.Chapters.First().Summary ?? string.Empty,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
@ -728,12 +743,13 @@ public class OpdsController : BaseApiController
};
}
private static FeedEntry CreateChapter(string apiKey, string title, int chapterId, int volumeId, int seriesId)
private static FeedEntry CreateChapter(string apiKey, string title, string summary, int chapterId, int volumeId, int seriesId)
{
return new FeedEntry()
{
Id = chapterId.ToString(),
Title = title,
Summary = summary ?? string.Empty,
Links = new List<FeedLink>()
{
CreateLink(FeedLinkRelation.SubSection, FeedLinkType.AtomNavigation,
@ -787,7 +803,7 @@ public class OpdsController : BaseApiController
CreateLink(FeedLinkRelation.Thumbnail, FeedLinkType.Image, $"/api/image/chapter-cover?chapterId={chapterId}"),
// We can't not include acc link in the feed, panels doesn't work with just page streaming option. We have to block download directly
accLink,
CreatePageStreamLink(seriesId, volumeId, chapterId, mangaFile, apiKey)
CreatePageStreamLink(series.LibraryId,seriesId, volumeId, chapterId, mangaFile, apiKey)
},
Content = new FeedEntryContent()
{
@ -800,7 +816,7 @@ public class OpdsController : BaseApiController
}
[HttpGet("{apiKey}/image")]
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
public async Task<ActionResult> GetPageStreamedImage(string apiKey, [FromQuery] int libraryId, [FromQuery] int seriesId, [FromQuery] int volumeId,[FromQuery] int chapterId, [FromQuery] int pageNumber)
{
if (pageNumber < 0) return BadRequest("Page cannot be less than 0");
var chapter = await _cacheService.Ensure(chapterId);
@ -808,11 +824,11 @@ public class OpdsController : BaseApiController
try
{
var path = _cacheService.GetCachedPagePath(chapter, pageNumber);
var path = _cacheService.GetCachedPagePath(chapter.Id, pageNumber);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {pageNumber}");
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
var format = Path.GetExtension(path).Replace(".", string.Empty);
// Calculates SHA1 Hash for byte[]
Response.AddCacheHeader(content);
@ -823,7 +839,8 @@ public class OpdsController : BaseApiController
ChapterId = chapterId,
PageNum = pageNumber,
SeriesId = seriesId,
VolumeId = volumeId
VolumeId = volumeId,
LibraryId =libraryId
}, await GetUser(apiKey));
return File(content, "image/" + format);
@ -843,7 +860,7 @@ public class OpdsController : BaseApiController
if (files.Length == 0) return BadRequest("Cannot find icon");
var path = files[0];
var content = await _directoryService.ReadFileAsync(path);
var format = Path.GetExtension(path).Replace(".", "");
var format = Path.GetExtension(path).Replace(".", string.Empty);
return File(content, "image/" + format);
}
@ -866,9 +883,9 @@ public class OpdsController : BaseApiController
throw new KavitaException("User does not exist");
}
private static FeedLink CreatePageStreamLink(int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
private static FeedLink CreatePageStreamLink(int libraryId, int seriesId, int volumeId, int chapterId, MangaFile mangaFile, string apiKey)
{
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
var link = CreateLink(FeedLinkRelation.Stream, "image/jpeg", $"{Prefix}{apiKey}/image?libraryId={libraryId}&seriesId={seriesId}&volumeId={volumeId}&chapterId={chapterId}&pageNumber=" + "{pageNumber}");
link.TotalPages = mangaFile.Pages;
return link;
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
@ -56,7 +57,7 @@ public class ReaderController : BaseApiController
/// <param name="chapterId"></param>
/// <returns></returns>
[HttpGet("pdf")]
[ResponseCache(CacheProfileName = "Hour")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
public async Task<ActionResult> GetPdf(int chapterId)
{
var chapter = await _cacheService.Ensure(chapterId);
@ -86,22 +87,22 @@ public class ReaderController : BaseApiController
/// Returns an image for a given chapter. Will perform bounding checks
/// </summary>
/// <remarks>This will cache the chapter images for reading</remarks>
/// <param name="chapterId"></param>
/// <param name="page"></param>
/// <param name="chapterId">Chapter Id</param>
/// <param name="page">Page in question</param>
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
/// <returns></returns>
[HttpGet("image")]
[ResponseCache(CacheProfileName = "Hour")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
[AllowAnonymous]
public async Task<ActionResult> GetImage(int chapterId, int page)
public async Task<ActionResult> GetImage(int chapterId, int page, bool extractPdf = false)
{
if (page < 0) page = 0;
var chapter = await _cacheService.Ensure(chapterId);
var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return BadRequest("There was an issue finding image file for reading");
try
{
// TODO: This code is very generic and repeated, see if we can refactor into a common method
var path = _cacheService.GetCachedPagePath(chapter, page);
var path = _cacheService.GetCachedPagePath(chapter.Id, page);
if (string.IsNullOrEmpty(path) || !System.IO.File.Exists(path)) return BadRequest($"No such image for page {page}. Try refreshing to allow re-cache.");
var format = Path.GetExtension(path).Replace(".", "");
@ -123,7 +124,7 @@ public class ReaderController : BaseApiController
/// <remarks>We must use api key as bookmarks could be leaked to other users via the API</remarks>
/// <returns></returns>
[HttpGet("bookmark-image")]
[ResponseCache(CacheProfileName = "Hour")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour)]
[AllowAnonymous]
public async Task<ActionResult> GetBookmarkImage(int seriesId, string apiKey, int page)
{
@ -151,21 +152,42 @@ public class ReaderController : BaseApiController
}
}
/// <summary>
/// Returns the file dimensions for all pages in a chapter. If the underlying chapter is PDF, use extractPDF to unpack as images.
/// </summary>
/// <remarks>This has a side effect of caching the images.
/// This will only be populated on archive filetypes and not in bookmark mode</remarks>
/// <param name="chapterId"></param>
/// <param name="extractPdf"></param>
/// <returns></returns>
[HttpGet("file-dimensions")]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf"})]
public async Task<ActionResult<IEnumerable<FileDimensionDto>>> GetFileDimensions(int chapterId, bool extractPdf = false)
{
if (chapterId <= 0) return null;
var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return BadRequest("Could not find Chapter");
return Ok(_cacheService.GetCachedFileDimensions(chapterId));
}
/// <summary>
/// Returns various information about a Chapter. Side effect: This will cache the chapter images for reading.
/// </summary>
/// <param name="chapterId"></param>
/// <param name="extractPdf">Should Kavita extract pdf into images. Defaults to false.</param>
/// <param name="includeDimensions">Include file dimensions. Only useful for image based reading</param>
/// <returns></returns>
[HttpGet("chapter-info")]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId)
[ResponseCache(CacheProfileName = ResponseCacheProfiles.Hour, VaryByQueryKeys = new []{"chapterId", "extractPdf", "includeDimensions"})]
public async Task<ActionResult<ChapterInfoDto>> GetChapterInfo(int chapterId, bool extractPdf = false, bool includeDimensions = false)
{
if (chapterId <= 0) return null; // This can happen occasionally from UI, we should just ignore
var chapter = await _cacheService.Ensure(chapterId);
var chapter = await _cacheService.Ensure(chapterId, extractPdf);
if (chapter == null) return BadRequest("Could not find Chapter");
var dto = await _unitOfWork.ChapterRepository.GetChapterInfoDtoAsync(chapterId);
if (dto == null) return BadRequest("Please perform a scan on this series or library and try again");
var mangaFile = (await _unitOfWork.ChapterRepository.GetFilesForChapterAsync(chapterId)).First();
var mangaFile = chapter.Files.First();
var info = new ChapterInfoDto()
{
@ -181,9 +203,15 @@ public class ReaderController : BaseApiController
Pages = dto.Pages,
ChapterTitle = dto.ChapterTitle ?? string.Empty,
Subtitle = string.Empty,
Title = dto.SeriesName
Title = dto.SeriesName,
};
if (includeDimensions)
{
info.PageDimensions = _cacheService.GetCachedFileDimensions(chapterId);
info.DoublePairs = _readerService.GetPairs(info.PageDimensions);
}
if (info.ChapterTitle is {Length: > 0}) {
info.Title += " - " + info.ChapterTitle;
}
@ -193,14 +221,14 @@ public class ReaderController : BaseApiController
info.Subtitle = info.FileName;
} else if (!info.IsSpecial && info.VolumeNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultVolume))
{
info.Subtitle = _readerService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber;
info.Subtitle = ReaderService.FormatChapterName(info.LibraryType, true, true) + info.ChapterNumber;
}
else
{
info.Subtitle = "Volume " + info.VolumeNumber;
if (!info.ChapterNumber.Equals(Services.Tasks.Scanner.Parser.Parser.DefaultChapter))
{
info.Subtitle += " " + _readerService.FormatChapterName(info.LibraryType, true, true) +
info.Subtitle += " " + ReaderService.FormatChapterName(info.LibraryType, true, true) +
info.ChapterNumber;
}
}
@ -216,8 +244,7 @@ public class ReaderController : BaseApiController
[HttpGet("bookmark-info")]
public async Task<ActionResult<BookmarkInfoDto>> GetBookmarkInfo(int seriesId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var totalPages = await _cacheService.CacheBookmarkForSeries(user.Id, seriesId);
var totalPages = await _cacheService.CacheBookmarkForSeries(User.GetUserId(), seriesId);
var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(seriesId, SeriesIncludes.None);
return Ok(new BookmarkInfoDto()
@ -423,25 +450,15 @@ public class ReaderController : BaseApiController
[HttpGet("get-progress")]
public async Task<ActionResult<ProgressDto>> GetProgress(int chapterId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.Progress);
var progressBookmark = new ProgressDto()
var progress = await _unitOfWork.AppUserProgressRepository.GetUserProgressDtoAsync(chapterId, User.GetUserId());
if (progress == null) return Ok(new ProgressDto()
{
PageNum = 0,
ChapterId = chapterId,
VolumeId = 0,
SeriesId = 0
};
if (user.Progresses == null) return Ok(progressBookmark);
var progress = user.Progresses.FirstOrDefault(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);
});
return Ok(progress);
}
/// <summary>
@ -452,9 +469,7 @@ public class ReaderController : BaseApiController
[HttpPost("progress")]
public async Task<ActionResult> BookmarkProgress(ProgressDto progressDto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (await _readerService.SaveReadingProgress(progressDto, user.Id)) return Ok(true);
if (await _readerService.SaveReadingProgress(progressDto, User.GetUserId())) return Ok(true);
return BadRequest("Could not save progress");
}
@ -478,7 +493,7 @@ public class ReaderController : BaseApiController
/// <param name="seriesId"></param>
/// <returns></returns>
[HttpGet("has-progress")]
public async Task<ActionResult<ChapterDto>> HasProgress(int seriesId)
public async Task<ActionResult<bool>> HasProgress(int seriesId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _unitOfWork.AppUserProgressRepository.HasAnyProgressOnSeriesAsync(seriesId, userId));
@ -671,7 +686,7 @@ public class ReaderController : BaseApiController
if (chapter == null) return BadRequest("Could not find cached image. Reload and try again.");
bookmarkDto.Page = _readerService.CapPageToChapter(chapter, bookmarkDto.Page);
var path = _cacheService.GetCachedPagePath(chapter, bookmarkDto.Page);
var path = _cacheService.GetCachedPagePath(chapter.Id, bookmarkDto.Page);
if (!await _bookmarkService.BookmarkPage(user, bookmarkDto, path)) return BadRequest("Could not save bookmark");

View File

@ -1,17 +1,22 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading.Tasks;
using API.Comparators;
using API.Data;
using API.Data.Repositories;
using API.DTOs.ReadingLists;
using API.DTOs.ReadingLists.CBL;
using API.Entities;
using API.Extensions;
using API.Helpers;
using API.Services;
using API.SignalR;
using Kavita.Common;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
@ -22,12 +27,14 @@ public class ReadingListController : BaseApiController
private readonly IUnitOfWork _unitOfWork;
private readonly IEventHub _eventHub;
private readonly IReadingListService _readingListService;
private readonly IDirectoryService _directoryService;
public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService)
public ReadingListController(IUnitOfWork unitOfWork, IEventHub eventHub, IReadingListService readingListService, IDirectoryService directoryService)
{
_unitOfWork = unitOfWork;
_eventHub = eventHub;
_readingListService = readingListService;
_directoryService = directoryService;
}
/// <summary>
@ -82,8 +89,7 @@ public class ReadingListController : BaseApiController
[HttpGet("items")]
public async Task<ActionResult<IEnumerable<ReadingListItemDto>>> GetListForUser(int readingListId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, userId);
var items = await _unitOfWork.ReadingListRepository.GetReadingListItemDtosByIdAsync(readingListId, User.GetUserId());
return Ok(items);
}
@ -181,21 +187,16 @@ public class ReadingListController : BaseApiController
public async Task<ActionResult<ReadingListDto>> CreateList(CreateReadingListDto dto)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingListsWithItems);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername(), AppUserIncludes.ReadingLists);
// When creating, we need to make sure Title is unique
var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title));
if (hasExisting)
try
{
return BadRequest("A list of this name already exists");
await _readingListService.CreateReadingListForUser(user, dto.Title);
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
var readingList = DbFactory.ReadingList(dto.Title, string.Empty, false);
user.ReadingLists.Add(readingList);
if (!_unitOfWork.HasChanges()) return BadRequest("There was a problem creating list");
await _unitOfWork.CommitAsync();
return Ok(await _unitOfWork.ReadingListRepository.GetReadingListDtoByTitleAsync(user.Id, dto.Title));
}
@ -217,44 +218,16 @@ public class ReadingListController : BaseApiController
return BadRequest("You do not have permissions on this reading list or the list doesn't exist");
}
dto.Title = dto.Title.Trim();
if (!string.IsNullOrEmpty(dto.Title))
try
{
readingList.Summary = dto.Summary;
if (!readingList.Title.Equals(dto.Title))
{
var hasExisting = user.ReadingLists.Any(l => l.Title.Equals(dto.Title));
if (hasExisting)
{
return BadRequest("A list of this name already exists");
}
readingList.Title = dto.Title;
readingList.NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(readingList.Title);
}
await _readingListService.UpdateReadingList(readingList, dto);
}
catch (KavitaException ex)
{
return BadRequest(ex.Message);
}
readingList.Promoted = dto.Promoted;
readingList.CoverImageLocked = dto.CoverImageLocked;
if (!dto.CoverImageLocked)
{
readingList.CoverImageLocked = false;
readingList.CoverImage = string.Empty;
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(readingList.Id, MessageFactoryEntityTypes.ReadingList), false);
_unitOfWork.ReadingListRepository.Update(readingList);
}
_unitOfWork.ReadingListRepository.Update(readingList);
if (await _unitOfWork.CommitAsync())
{
return Ok("Updated");
}
return BadRequest("Could not update reading list");
return Ok("Updated");
}
/// <summary>
@ -498,4 +471,35 @@ public class ReadingListController : BaseApiController
return Ok(-1);
}
/// <summary>
/// Checks if a reading list exists with the name
/// </summary>
/// <param name="name">If empty or null, will return true as that is invalid</param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("name-exists")]
public async Task<ActionResult<bool>> DoesNameExists(string name)
{
if (string.IsNullOrEmpty(name)) return true;
return Ok(await _unitOfWork.ReadingListRepository.ReadingListExists(name));
}
// [HttpPost("import-cbl")]
// public async Task<ActionResult<CblImportSummaryDto>> ImportCbl([FromForm(Name = "cbl")] IFormFile file, [FromForm(Name = "dryRun")] bool dryRun = false)
// {
// var userId = User.GetUserId();
// var filename = Path.GetRandomFileName();
// var outputFile = Path.Join(_directoryService.TempDirectory, filename);
//
// await using var stream = System.IO.File.Create(outputFile);
// await file.CopyToAsync(stream);
// stream.Close();
// var cbl = ReadingListService.LoadCblFromPath(outputFile);
//
// // We need to pass the temp file back
//
// var importSummary = await _readingListService.ValidateCblFile(userId, cbl);
// return importSummary.Results.Any() ? Ok(importSummary) : Ok(await _readingListService.CreateReadingListFromCbl(userId, cbl, dryRun));
// }
}

View File

@ -26,10 +26,8 @@ public class RecommendedController : BaseApiController
[HttpGet("quick-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickReads(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetQuickReads(user.Id, libraryId, userParams);
var series = await _unitOfWork.SeriesRepository.GetQuickReads(User.GetUserId(), libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
@ -44,10 +42,8 @@ public class RecommendedController : BaseApiController
[HttpGet("quick-catchup-reads")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetQuickCatchupReads(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(user.Id, libraryId, userParams);
var series = await _unitOfWork.SeriesRepository.GetQuickCatchupReads(User.GetUserId(), libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
@ -62,11 +58,10 @@ public class RecommendedController : BaseApiController
[HttpGet("highly-rated")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetHighlyRated(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetHighlyRated(user.Id, libraryId, userParams);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series);
var series = await _unitOfWork.SeriesRepository.GetHighlyRated(userId, libraryId, userParams);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
}
@ -81,11 +76,11 @@ public class RecommendedController : BaseApiController
[HttpGet("more-in")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetMoreIn(int libraryId, int genreId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var userId = User.GetUserId();
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetMoreIn(user.Id, libraryId, genreId, userParams);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(user.Id, series);
var series = await _unitOfWork.SeriesRepository.GetMoreIn(userId, libraryId, genreId, userParams);
await _unitOfWork.SeriesRepository.AddSeriesModifiers(userId, series);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);
@ -100,10 +95,8 @@ public class RecommendedController : BaseApiController
[HttpGet("rediscover")]
public async Task<ActionResult<PagedList<SeriesDto>>> GetRediscover(int libraryId, [FromQuery] UserParams userParams)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
userParams ??= new UserParams();
var series = await _unitOfWork.SeriesRepository.GetRediscover(user.Id, libraryId, userParams);
var series = await _unitOfWork.SeriesRepository.GetRediscover(User.GetUserId(), libraryId, userParams);
Response.AddPaginationHeader(series.CurrentPage, series.PageSize, series.TotalCount, series.TotalPages);
return Ok(series);

View File

@ -2,6 +2,7 @@
using System.Linq;
using System.Threading.Tasks;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Search;
using API.Extensions;
@ -50,17 +51,16 @@ public class SearchController : BaseApiController
[HttpGet("search")]
public async Task<ActionResult<SearchResultGroupDto>> Search(string queryString)
{
queryString = Uri.UnescapeDataString(queryString).Trim().Replace(@"%", string.Empty).Replace(":", string.Empty);
queryString = Services.Tasks.Scanner.Parser.Parser.CleanQuery(queryString);
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
// Get libraries user has access to
var libraries = (await _unitOfWork.LibraryRepository.GetLibrariesForUserIdAsync(user.Id)).ToList();
var libraries = _unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(user.Id, QueryContext.Search).ToList();
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
if (!libraries.Any()) return BadRequest("User does not have access to any libraries");
var isAdmin = await _unitOfWork.UserRepository.IsUserAdminAsync(user);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin, libraries.Select(l => l.Id).ToArray(), queryString);
var series = await _unitOfWork.SeriesRepository.SearchSeries(user.Id, isAdmin,
libraries, queryString);
return Ok(series);
}

View File

@ -2,10 +2,12 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.Data.Repositories;
using API.DTOs;
using API.DTOs.Filtering;
using API.DTOs.Metadata;
using API.DTOs.SeriesDetail;
using API.Entities;
using API.Entities.Enums;
@ -120,11 +122,12 @@ public class SeriesController : BaseApiController
[HttpGet("chapter")]
public async Task<ActionResult<ChapterDto>> GetChapter(int chapterId)
{
return Ok(await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId));
var chapter = await _unitOfWork.ChapterRepository.GetChapterDtoAsync(chapterId);
return Ok(await _unitOfWork.ChapterRepository.AddChapterModifiers(User.GetUserId(), chapter));
}
[HttpGet("chapter-metadata")]
public async Task<ActionResult<ChapterDto>> GetChapterMetadata(int chapterId)
public async Task<ActionResult<ChapterMetadataDto>> GetChapterMetadata(int chapterId)
{
return Ok(await _unitOfWork.ChapterRepository.GetChapterMetadataDtoAsync(chapterId));
}
@ -367,7 +370,7 @@ public class SeriesController : BaseApiController
/// <param name="ageRating"></param>
/// <returns></returns>
/// <remarks>This is cached for an hour</remarks>
[ResponseCache(CacheProfileName = "Hour", VaryByQueryKeys = new [] {"ageRating"})]
[ResponseCache(CacheProfileName = "Month", VaryByQueryKeys = new [] {"ageRating"})]
[HttpGet("age-rating")]
public ActionResult<string> GetAgeRating(int ageRating)
{
@ -383,7 +386,7 @@ public class SeriesController : BaseApiController
/// <param name="seriesId"></param>
/// <returns></returns>
/// <remarks>Do not rely on this API externally. May change without hesitation. </remarks>
[ResponseCache(CacheProfileName = "5Minute", VaryByQueryKeys = new [] {"seriesId"})]
[ResponseCache(CacheProfileName = ResponseCacheProfiles.FiveMinute, VaryByQueryKeys = new [] {"seriesId"})]
[HttpGet("series-detail")]
public async Task<ActionResult<SeriesDetailDto>> GetSeriesDetailBreakdown(int seriesId)
{

View File

@ -19,7 +19,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using TaskScheduler = System.Threading.Tasks.TaskScheduler;
using TaskScheduler = API.Services.TaskScheduler;
namespace API.Controllers;
@ -35,10 +35,12 @@ public class ServerController : BaseApiController
private readonly ICleanupService _cleanupService;
private readonly IEmailService _emailService;
private readonly IBookmarkService _bookmarkService;
private readonly IScannerService _scannerService;
private readonly IAccountService _accountService;
public ServerController(IHostApplicationLifetime applicationLifetime, ILogger<ServerController> logger,
IBackupService backupService, IArchiveService archiveService, IVersionUpdaterService versionUpdaterService, IStatsService statsService,
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService)
ICleanupService cleanupService, IEmailService emailService, IBookmarkService bookmarkService, IScannerService scannerService, IAccountService accountService)
{
_applicationLifetime = applicationLifetime;
_logger = logger;
@ -49,6 +51,8 @@ public class ServerController : BaseApiController
_cleanupService = cleanupService;
_emailService = emailService;
_bookmarkService = bookmarkService;
_scannerService = scannerService;
_accountService = accountService;
}
/// <summary>
@ -85,7 +89,7 @@ public class ServerController : BaseApiController
public ActionResult CleanupWantToRead()
{
_logger.LogInformation("{UserName} is clearing running want to read cleanup from admin dashboard", User.GetUsername());
RecurringJob.TriggerJob(API.Services.TaskScheduler.RemoveFromWantToReadTaskId);
RecurringJob.TriggerJob(TaskScheduler.RemoveFromWantToReadTaskId);
return Ok();
}
@ -98,7 +102,23 @@ public class ServerController : BaseApiController
public ActionResult BackupDatabase()
{
_logger.LogInformation("{UserName} is backing up database of server from admin dashboard", User.GetUsername());
RecurringJob.TriggerJob(API.Services.TaskScheduler.BackupTaskId);
RecurringJob.TriggerJob(TaskScheduler.BackupTaskId);
return Ok();
}
/// <summary>
/// This is a one time task that needs to be ran for v0.7 statistics to work
/// </summary>
/// <returns></returns>
[HttpPost("analyze-files")]
public ActionResult AnalyzeFiles()
{
_logger.LogInformation("{UserName} is performing file analysis from admin dashboard", User.GetUsername());
if (TaskScheduler.HasAlreadyEnqueuedTask(ScannerService.Name, "AnalyzeFiles",
Array.Empty<object>(), TaskScheduler.DefaultQueue, true))
return Ok("Job already running");
BackgroundJob.Enqueue(() => _scannerService.AnalyzeFiles());
return Ok();
}
@ -119,10 +139,25 @@ public class ServerController : BaseApiController
[HttpPost("convert-bookmarks")]
public ActionResult ScheduleConvertBookmarks()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllBookmarkToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllBookmarkToWebP());
return Ok();
}
/// <summary>
/// Triggers the scheduling of the convert covers job. Only one job will run at a time.
/// </summary>
/// <returns></returns>
[HttpPost("convert-covers")]
public ActionResult ScheduleConvertCovers()
{
if (TaskScheduler.HasAlreadyEnqueuedTask(BookmarkService.Name, "ConvertAllCoverToWebP", Array.Empty<object>(),
TaskScheduler.DefaultQueue, true)) return Ok();
BackgroundJob.Enqueue(() => _bookmarkService.ConvertAllCoverToWebP());
return Ok();
}
[HttpGet("logs")]
public ActionResult GetLogs()
{
@ -156,12 +191,13 @@ public class ServerController : BaseApiController
/// <summary>
/// Is this server accessible to the outside net
/// </summary>
/// <remarks>If the instance has the HostName set, this will return true whether or not it is accessible externally</remarks>
/// <returns></returns>
[HttpGet("accessible")]
[AllowAnonymous]
public async Task<ActionResult<bool>> IsServerAccessible()
{
return await _emailService.CheckIfAccessible(Request.Host.ToString());
return Ok(await _accountService.CheckIfAccessible(Request));
}
[HttpGet("jobs")]
@ -177,8 +213,6 @@ public class ServerController : BaseApiController
LastExecution = dto.LastExecution,
});
// For now, let's just do something simple
//var enqueuedJobs = JobStorage.Current.GetMonitoringApi().EnqueuedJobs("default", 0, int.MaxValue);
return Ok(recurringJobs);
}

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using API.Data;
using API.DTOs.Email;
@ -70,6 +71,27 @@ public class SettingsController : BaseApiController
return await UpdateSettings(_mapper.Map<ServerSettingDto>(Seed.DefaultSettings));
}
/// <summary>
/// Resets the IP Addresses
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpPost("reset-ip-addresses")]
public async Task<ActionResult<ServerSettingDto>> ResetIPAddressesSettings()
{
_logger.LogInformation("{UserName} is resetting IP Addresses Setting", User.GetUsername());
var ipAddresses = await _unitOfWork.SettingsRepository.GetSettingAsync(ServerSettingKey.IpAddresses);
ipAddresses.Value = Configuration.DefaultIPAddresses;
_unitOfWork.SettingsRepository.Update(ipAddresses);
if (!await _unitOfWork.CommitAsync())
{
await _unitOfWork.RollbackAsync();
}
return Ok(await _unitOfWork.SettingsRepository.GetSettingsDtoAsync());
}
/// <summary>
/// Resets the email service url
/// </summary>
@ -104,7 +126,7 @@ public class SettingsController : BaseApiController
[HttpPost]
public async Task<ActionResult<ServerSettingDto>> UpdateSettings(ServerSettingDto updateSettingsDto)
{
_logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername());
_logger.LogInformation("{UserName} is updating Server Settings", User.GetUsername());
// We do not allow CacheDirectory changes, so we will ignore.
var currentSettings = await _unitOfWork.SettingsRepository.GetSettingsAsync();
@ -145,6 +167,22 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.IpAddresses && updateSettingsDto.IpAddresses != setting.Value)
{
// Validate IP addresses
foreach (var ipAddress in updateSettingsDto.IpAddresses.Split(','))
{
if (!IPAddress.TryParse(ipAddress.Trim(), out _)) {
return BadRequest($"IP Address '{ipAddress}' is invalid");
}
}
setting.Value = updateSettingsDto.IpAddresses;
// IpAddesses is managed in appSetting.json
Configuration.IpAddresses = updateSettingsDto.IpAddresses;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.BaseUrl && updateSettingsDto.BaseUrl + string.Empty != setting.Value)
{
var path = !updateSettingsDto.BaseUrl.StartsWith("/")
@ -176,6 +214,19 @@ public class SettingsController : BaseApiController
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.ConvertCoverToWebP && updateSettingsDto.ConvertCoverToWebP + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.ConvertCoverToWebP + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.HostName && updateSettingsDto.HostName + string.Empty != setting.Value)
{
setting.Value = (updateSettingsDto.HostName + string.Empty).Trim();
if (setting.Value.EndsWith("/")) setting.Value = setting.Value.Substring(0, setting.Value.Length - 1);
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.BookmarkDirectory && bookmarkDirectory != setting.Value)
{
@ -207,12 +258,6 @@ public class SettingsController : BaseApiController
}
}
if (setting.Key == ServerSettingKey.EnableSwaggerUi && updateSettingsDto.EnableSwaggerUi + string.Empty != setting.Value)
{
setting.Value = updateSettingsDto.EnableSwaggerUi + string.Empty;
_unitOfWork.SettingsRepository.Update(setting);
}
if (setting.Key == ServerSettingKey.TotalBackups && updateSettingsDto.TotalBackups + string.Empty != setting.Value)
{
if (updateSettingsDto.TotalBackups > 30 || updateSettingsDto.TotalBackups < 1)

View File

@ -0,0 +1,174 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using API.Constants;
using API.Data;
using API.DTOs.Statistics;
using API.Entities;
using API.Entities.Enums;
using API.Extensions;
using API.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
namespace API.Controllers;
public class StatsController : BaseApiController
{
private readonly IStatisticService _statService;
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<AppUser> _userManager;
public StatsController(IStatisticService statService, IUnitOfWork unitOfWork, UserManager<AppUser> userManager)
{
_statService = statService;
_unitOfWork = unitOfWork;
_userManager = userManager;
}
[HttpGet("user/{userId}/read")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<UserReadStatistics>> GetUserReadStatistics(int userId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
if (user.Id != userId && !await _userManager.IsInRoleAsync(user, PolicyConstants.AdminRole))
return Unauthorized("You are not authorized to view another user's statistics");
return Ok(await _statService.GetUserReadStatistics(userId, new List<int>()));
}
[Authorize("RequireAdminRole")]
[HttpGet("server/stats")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<ServerStatisticsDto>> GetHighLevelStats()
{
return Ok(await _statService.GetServerStatistics());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/count/year")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetYearStatistics()
{
return Ok(await _statService.GetYearCount());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/count/publication-status")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<PublicationStatus>>>> GetPublicationStatus()
{
return Ok(await _statService.GetPublicationCount());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/count/manga-format")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<MangaFormat>>>> GetMangaFormat()
{
return Ok(await _statService.GetMangaFormatCount());
}
[Authorize("RequireAdminRole")]
[HttpGet("server/top/years")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetTopYears()
{
return Ok(await _statService.GetTopYears());
}
/// <summary>
/// Returns users with the top reads in the server
/// </summary>
/// <param name="days"></param>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("server/top/users")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<TopReadDto>>> GetTopReads(int days = 0)
{
return Ok(await _statService.GetTopUsers(days));
}
/// <summary>
/// A breakdown of different files, their size, and format
/// </summary>
/// <returns></returns>
[Authorize("RequireAdminRole")]
[HttpGet("server/file-breakdown")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<FileExtensionBreakdownDto>>> GetFileSize()
{
return Ok(await _statService.GetFileBreakdown());
}
/// <summary>
/// Returns reading history events for a give or all users, broken up by day, and format
/// </summary>
/// <param name="userId">If 0, defaults to all users, else just userId</param>
/// <param name="days">If 0, defaults to all time, else just those days asked for</param>
/// <returns></returns>
[HttpGet("reading-count-by-day")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<PagesReadOnADayCount<DateTime>>>> ReadCountByDay(int userId = 0, int days = 0)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin && userId != user.Id) return BadRequest();
return Ok(await _statService.ReadCountByDay(userId, days));
}
[HttpGet("day-breakdown")]
[Authorize("RequireAdminRole")]
[ResponseCache(CacheProfileName = "Statistics")]
public ActionResult<IEnumerable<StatCount<DayOfWeek>>> GetDayBreakdown()
{
return Ok(_statService.GetDayBreakdown());
}
[HttpGet("user/reading-history")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<ReadHistoryEvent>>> GetReadingHistory(int userId)
{
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin && userId != user.Id) return BadRequest();
return Ok(await _statService.GetReadingHistory(userId));
}
/// <summary>
/// Returns a count of pages read per year for a given userId.
/// </summary>
/// <param name="userId">If userId is 0 and user is not an admin, API will default to userId</param>
/// <returns></returns>
[HttpGet("pages-per-year")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetPagesReadPerYear(int userId = 0)
{
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(_statService.GetPagesReadCountByYear(userId));
}
/// <summary>
/// Returns a count of words read per year for a given userId.
/// </summary>
/// <param name="userId">If userId is 0 and user is not an admin, API will default to userId</param>
/// <returns></returns>
[HttpGet("words-per-year")]
[ResponseCache(CacheProfileName = "Statistics")]
public async Task<ActionResult<IEnumerable<StatCount<int>>>> GetWordsReadPerYear(int userId = 0)
{
var isAdmin = User.IsInRole(PolicyConstants.AdminRole);
if (!isAdmin) userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(_statService.GetWordsReadCountByYear(userId));
}
}

View File

@ -32,8 +32,7 @@ public class TachiyomiController : BaseApiController
public async Task<ActionResult<ChapterDto>> GetLatestChapter(int seriesId)
{
if (seriesId < 1) return BadRequest("seriesId must be greater than 0");
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
return Ok(await _tachiyomiService.GetLatestChapter(seriesId, userId));
return Ok(await _tachiyomiService.GetLatestChapter(seriesId, User.GetUserId()));
}
/// <summary>

View File

@ -17,7 +17,6 @@ namespace API.Controllers;
/// <summary>
///
/// </summary>
[Authorize(Policy = "RequireAdminRole")]
public class UploadController : BaseApiController
{
private readonly IUnitOfWork _unitOfWork;
@ -26,10 +25,11 @@ public class UploadController : BaseApiController
private readonly ITaskScheduler _taskScheduler;
private readonly IDirectoryService _directoryService;
private readonly IEventHub _eventHub;
private readonly IReadingListService _readingListService;
/// <inheritdoc />
public UploadController(IUnitOfWork unitOfWork, IImageService imageService, ILogger<UploadController> logger,
ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub)
ITaskScheduler taskScheduler, IDirectoryService directoryService, IEventHub eventHub, IReadingListService readingListService)
{
_unitOfWork = unitOfWork;
_imageService = imageService;
@ -37,6 +37,7 @@ public class UploadController : BaseApiController
_taskScheduler = taskScheduler;
_directoryService = directoryService;
_eventHub = eventHub;
_readingListService = readingListService;
}
/// <summary>
@ -49,7 +50,7 @@ public class UploadController : BaseApiController
[HttpPost("upload-by-url")]
public async Task<ActionResult<string>> GetImageFromFile(UploadUrlDto dto)
{
var dateString = $"{DateTime.Now.ToShortDateString()}_{DateTime.Now.ToLongTimeString()}".Replace('/', '_').Replace(':', '_');
var dateString = $"{DateTime.UtcNow.ToShortDateString()}_{DateTime.UtcNow.ToLongTimeString()}".Replace('/', '_').Replace(':', '_');
var format = _directoryService.FileSystem.Path.GetExtension(dto.Url.Split('?')[0]).Replace(".", string.Empty);
try
{
@ -170,9 +171,9 @@ public class UploadController : BaseApiController
/// <summary>
/// Replaces reading list cover image and locks it with a base64 encoded image
/// </summary>
/// <remarks>This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission</remarks>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("reading-list")]
public async Task<ActionResult> UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto)
@ -184,6 +185,9 @@ public class UploadController : BaseApiController
return BadRequest("You must pass a url to use");
}
if (_readingListService.UserHasReadingListAccess(uploadFileDto.Id, User.GetUsername()) == null)
return Unauthorized("You do not have access");
try
{
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}");
@ -266,6 +270,64 @@ public class UploadController : BaseApiController
return BadRequest("Unable to save cover image to Chapter");
}
/// <summary>
/// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null.
/// </summary>
/// <param name="uploadFileDto"></param>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[RequestSizeLimit(8_000_000)]
[HttpPost("library")]
public async Task<ActionResult> UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto)
{
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(uploadFileDto.Id);
if (library == null) return BadRequest("This library does not exist");
// 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))
{
library.CoverImage = null;
_unitOfWork.LibraryRepository.Update(library);
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false);
}
return Ok();
}
try
{
var filePath = _imageService.CreateThumbnailFromBase64(uploadFileDto.Url,
$"{ImageService.GetLibraryFormat(uploadFileDto.Id)}", ImageService.LibraryThumbnailWidth);
if (!string.IsNullOrEmpty(filePath))
{
library.CoverImage = filePath;
_unitOfWork.LibraryRepository.Update(library);
}
if (_unitOfWork.HasChanges())
{
await _unitOfWork.CommitAsync();
await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate,
MessageFactory.CoverUpdateEvent(library.Id, MessageFactoryEntityTypes.Library), false);
return Ok();
}
}
catch (Exception e)
{
_logger.LogError(e, "There was an issue uploading cover image for Library {Id}", uploadFileDto.Id);
await _unitOfWork.RollbackAsync();
}
return BadRequest("Unable to save cover image to Library");
}
/// <summary>
/// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image.
/// </summary>

View File

@ -55,12 +55,19 @@ public class UsersController : BaseApiController
return Ok(await _unitOfWork.UserRepository.GetPendingMemberDtosAsync());
}
[HttpGet("myself")]
public async Task<ActionResult<IEnumerable<MemberDto>>> GetMyself()
{
var users = await _unitOfWork.UserRepository.GetAllUsersAsync();
return Ok(users.Where(u => u.UserName == User.GetUsername()).DefaultIfEmpty().Select(u => _mapper.Map<MemberDto>(u)).SingleOrDefault());
}
[HttpGet("has-reading-progress")]
public async Task<ActionResult<bool>> HasReadingProgress(int libraryId)
{
var userId = await _unitOfWork.UserRepository.GetUserIdByUsernameAsync(User.GetUsername());
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId, LibraryIncludes.None);
var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId);
return Ok(await _unitOfWork.AppUserProgressRepository.UserHasProgress(library.Type, userId));
}
@ -85,6 +92,7 @@ public class UsersController : BaseApiController
existingPreferences.PageSplitOption = preferencesDto.PageSplitOption;
existingPreferences.AutoCloseMenu = preferencesDto.AutoCloseMenu;
existingPreferences.ShowScreenHints = preferencesDto.ShowScreenHints;
existingPreferences.EmulateBook = preferencesDto.EmulateBook;
existingPreferences.ReaderMode = preferencesDto.ReaderMode;
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.BackgroundColor = string.IsNullOrEmpty(preferencesDto.BackgroundColor) ? "#000000" : preferencesDto.BackgroundColor;
@ -103,6 +111,7 @@ public class UsersController : BaseApiController
existingPreferences.LayoutMode = preferencesDto.LayoutMode;
existingPreferences.PromptForDownloadSize = preferencesDto.PromptForDownloadSize;
existingPreferences.NoTransitions = preferencesDto.NoTransitions;
existingPreferences.SwipeToPaginate = preferencesDto.SwipeToPaginate;
_unitOfWork.UserRepository.Update(existingPreferences);
@ -115,6 +124,10 @@ public class UsersController : BaseApiController
return BadRequest("There was an issue saving preferences.");
}
/// <summary>
/// Returns the preferences of the user
/// </summary>
/// <returns></returns>
[HttpGet("get-preferences")]
public async Task<ActionResult<UserPreferencesDto>> GetPreferences()
{
@ -122,4 +135,15 @@ public class UsersController : BaseApiController
await _unitOfWork.UserRepository.GetPreferencesAsync(User.GetUsername()));
}
/// <summary>
/// Returns a list of the user names within the system
/// </summary>
/// <returns></returns>
[Authorize(Policy = "RequireAdminRole")]
[HttpGet("names")]
public async Task<ActionResult<IEnumerable<string>>> GetUserNames()
{
return Ok((await _unitOfWork.UserRepository.GetAllUsersAsync()).Select(u => u.UserName));
}
}

View File

@ -35,12 +35,17 @@ public class WantToReadController : BaseApiController
public async Task<ActionResult<PagedList<SeriesDto>>> GetWantToRead([FromQuery] UserParams userParams, FilterDto filterDto)
{
userParams ??= new UserParams();
var user = await _unitOfWork.UserRepository.GetUserByUsernameAsync(User.GetUsername());
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(user.Id, userParams, filterDto);
var pagedList = await _unitOfWork.SeriesRepository.GetWantToReadForUserAsync(User.GetUserId(), userParams, filterDto);
Response.AddPaginationHeader(pagedList.CurrentPage, pagedList.PageSize, pagedList.TotalCount, pagedList.TotalPages);
return Ok(pagedList);
}
[HttpGet]
public async Task<ActionResult<bool>> IsSeriesInWantToRead([FromQuery] int seriesId)
{
return Ok(await _unitOfWork.SeriesRepository.IsSeriesInWantToRead(User.GetUserId(), seriesId));
}
/// <summary>
/// Given a list of Series Ids, add them to the current logged in user's Want To Read list
/// </summary>

View File

@ -3,4 +3,5 @@
public class UpdateEmailDto
{
public string Email { get; set; }
public string Password { get; set; }
}

View File

@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
using API.DTOs.Metadata;
using API.DTOs.Reader;
using API.Entities.Enums;
using API.Entities.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace API.DTOs;
@ -11,7 +10,7 @@ namespace API.DTOs;
/// 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 : IHasReadTimeEstimate
public class ChapterDto : IHasReadTimeEstimate, IEntityDate
{
public int Id { get; init; }
/// <summary>
@ -43,6 +42,10 @@ public class ChapterDto : IHasReadTimeEstimate
/// </summary>
public int PagesRead { get; set; }
/// <summary>
/// The last time a chapter was read by current authenticated user
/// </summary>
public DateTime LastReadingProgressUtc { get; set; }
/// <summary>
/// If the Cover Image is locked for this entity
/// </summary>
public bool CoverImageLocked { get; set; }
@ -53,7 +56,10 @@ public class ChapterDto : IHasReadTimeEstimate
/// <summary>
/// When chapter was created
/// </summary>
public DateTime Created { get; init; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
/// <summary>
/// When the chapter was released.
/// </summary>
@ -77,7 +83,6 @@ public class ChapterDto : IHasReadTimeEstimate
/// Total words in a Chapter (books only)
/// </summary>
public long WordCount { get; set; } = 0L;
/// <summary>
/// Formatted Volume title ie) Volume 2.
/// </summary>

View File

@ -30,4 +30,8 @@ public class DeviceDto
/// Last time this device was used to send a file
/// </summary>
public DateTime LastUsed { get; set; }
/// <summary>
/// Last time this device was used to send a file
/// </summary>
public DateTime LastUsedUtc { get; set; }
}

View File

@ -20,5 +20,13 @@ public class JobDto
/// Last time the job was run
/// </summary>
public DateTime? LastExecution { get; set; }
/// <summary>
/// When the job was created
/// </summary>
public DateTime? CreatedAtUtc { get; set; }
/// <summary>
/// Last time the job was run
/// </summary>
public DateTime? LastExecutionUtc { get; set; }
public string Cron { get; set; }
}

View File

@ -13,5 +13,29 @@ public class LibraryDto
/// </summary>
public DateTime LastScanned { get; init; }
public LibraryType Type { get; init; }
/// <summary>
/// An optional Cover Image or null
/// </summary>
public string CoverImage { get; init; }
/// <summary>
/// If Folder Watching is enabled for this library
/// </summary>
public bool FolderWatching { get; set; } = true;
/// <summary>
/// Include Library series on Dashboard Streams
/// </summary>
public bool IncludeInDashboard { get; set; } = true;
/// <summary>
/// Include Library series on Recommended Streams
/// </summary>
public bool IncludeInRecommended { get; set; } = true;
/// <summary>
/// Should this library create and manage collections from Metadata
/// </summary>
public bool ManageCollections { get; set; } = true;
/// <summary>
/// Include library series in Search
/// </summary>
public bool IncludeInSearch { get; set; } = true;
public ICollection<string> Folders { get; init; }
}

View File

@ -8,6 +8,7 @@ public class MangaFileDto
public int Id { get; init; }
public string FilePath { get; init; }
public int Pages { get; init; }
public long Bytes { get; init; }
public MangaFormat Format { get; init; }
public DateTime Created { get; init; }

View File

@ -0,0 +1,18 @@
using System.Xml.Serialization;
namespace API.DTOs.OPDS;
public class FeedCategory
{
[XmlAttribute("scheme")]
public string Scheme { get; } = "http://www.bisg.org/standards/bisac_subject/index.html";
[XmlAttribute("term")]
public string Term { get; set; }
/// <summary>
/// The actual genre
/// </summary>
[XmlAttribute("label")]
public string Label { get; set; }
}

View File

@ -40,11 +40,11 @@ public class FeedEntry
public FeedEntryContent Content { get; set; }
[XmlElement("link")]
public List<FeedLink> Links = new List<FeedLink>();
public List<FeedLink> Links { get; set; } = new List<FeedLink>();
// [XmlElement("author")]
// public List<FeedAuthor> Authors = new List<FeedAuthor>();
[XmlElement("author")]
public List<FeedAuthor> Authors { get; set; } = new List<FeedAuthor>();
// [XmlElement("category")]
// public List<FeedCategory> Categories = new List<FeedCategory>();
[XmlElement("category")]
public List<FeedCategory> Categories { get; set; } = new List<FeedCategory>();
}

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System;
using System.ComponentModel.DataAnnotations;
namespace API.DTOs;
@ -12,8 +13,10 @@ public class ProgressDto
public int PageNum { get; set; }
[Required]
public int SeriesId { get; set; }
[Required]
public int LibraryId { get; set; }
/// <summary>
/// For Book reader, this can be an optional string of the id of a part marker, to help resume reading position
/// For EPUB 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

@ -1,4 +1,5 @@
using API.Entities.Enums;
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs.Reader;
@ -64,5 +65,15 @@ public class ChapterInfoDto : IChapterInfoDto
/// </summary>
/// <remarks>Usually just series name, but can include chapter title</remarks>
public string Title { get; set; }
/// <summary>
/// List of all files with their inner archive structure maintained in filename and dimensions
/// </summary>
/// <remarks>This is optionally returned by includeDimensions</remarks>
public IEnumerable<FileDimensionDto> PageDimensions { get; set; }
/// <summary>
/// For Double Page reader, this will contain snap points to ensure the reader always resumes on correct page
/// </summary>
/// <remarks>This is optionally returned by includeDimensions</remarks>
public IDictionary<int, int> DoublePairs { get; set; }
}

View File

@ -0,0 +1,14 @@
namespace API.DTOs.Reader;
public class FileDimensionDto
{
public int Width { get; set; }
public int Height { get; set; }
public int PageNumber { get; set; }
/// <summary>
/// The filename of the cached file. If this was nested in a subfolder, the foldername will be appended with _
/// </summary>
/// <example>chapter01_page01.png</example>
public string FileName { get; set; } = default!;
public bool IsWide { get; set; }
}

View File

@ -0,0 +1,29 @@
using System.Xml.Serialization;
namespace API.DTOs.ReadingLists.CBL;
[XmlRoot(ElementName="Book")]
public class CblBook
{
[XmlAttribute("Series")]
public string Series { get; set; }
/// <summary>
/// Chapter Number
/// </summary>
[XmlAttribute("Number")]
public string Number { get; set; }
/// <summary>
/// Volume Number (usually for Comics they are the year)
/// </summary>
[XmlAttribute("Volume")]
public string Volume { get; set; }
[XmlAttribute("Year")]
public string Year { get; set; }
/// <summary>
/// The underlying filetype
/// </summary>
/// <remarks>This is not part of the standard and explicitly for Kavita to support non cbz/cbr files</remarks>
[XmlAttribute("FileType")]
public string FileType { get; set; }
}

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace API.DTOs.ReadingLists.CBL;
public class CblConflictQuestion
{
public string SeriesName { get; set; }
public IList<int> LibrariesIds { get; set; }
}

View File

@ -0,0 +1,104 @@
using System.Collections.Generic;
using System.ComponentModel;
using API.DTOs.ReadingLists.CBL;
namespace API.DTOs.ReadingLists;
public enum CblImportResult {
/// <summary>
/// There was an issue which prevented processing
/// </summary>
[Description("Fail")]
Fail = 0,
/// <summary>
/// Some items were added, but not all
/// </summary>
[Description("Partial")]
Partial = 1,
/// <summary>
/// Everything was imported correctly
/// </summary>
[Description("Success")]
Success = 2
}
public enum CblImportReason
{
/// <summary>
/// The Chapter is not present in Kavita
/// </summary>
[Description("Chapter missing")]
ChapterMissing = 0,
/// <summary>
/// The Volume is not present in Kavita or no Volume field present in CBL and there is no chapter matching
/// </summary>
[Description("Volume missing")]
VolumeMissing = 1,
/// <summary>
/// The Series is not present in Kavita or the user does not have access to the Series due to some account restrictions
/// </summary>
[Description("Series missing")]
SeriesMissing = 2,
/// <summary>
/// The CBL Name conflicts with another Reading List in the system
/// </summary>
[Description("Name Conflict")]
NameConflict = 3,
/// <summary>
/// Every Series in the Reading list is missing from within Kavita or user has access restrictions to
/// </summary>
[Description("All Series Missing")]
AllSeriesMissing = 4,
/// <summary>
/// There are no Book entries in the CBL
/// </summary>
[Description("Empty File")]
EmptyFile = 5,
/// <summary>
/// Series Collides between Libraries
/// </summary>
[Description("Series Collision")]
SeriesCollision = 6,
/// <summary>
/// Every book chapter is missing or can't be matched
/// </summary>
[Description("All Chapters Missing")]
AllChapterMissing = 7,
}
public class CblBookResult
{
public string Series { get; set; }
public string Volume { get; set; }
public string Number { get; set; }
public CblImportReason Reason { get; set; }
public CblBookResult(CblBook book)
{
Series = book.Series;
Volume = book.Volume;
Number = book.Number;
}
public CblBookResult()
{
}
}
/// <summary>
/// Represents the summary from the Import of a given CBL
/// </summary>
public class CblImportSummaryDto
{
public string CblName { get; set; }
public ICollection<CblBookResult> Results { get; set; }
public CblImportResult Success { get; set; }
public ICollection<CblBookResult> SuccessfulInserts { get; set; }
/// <summary>
/// A list of Series that are within the CBL but map to multiple libraries within Kavita
/// </summary>
public IList<SeriesDto> Conflicts { get; set; }
public IList<CblConflictQuestion> Conflicts2 { get; set; }
}

View File

@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Xml.Serialization;
namespace API.DTOs.ReadingLists.CBL;
[XmlRoot(ElementName="Books")]
public class CblBooks
{
[XmlElement(ElementName="Book")]
public List<CblBook> Book { get; set; }
}
[XmlRoot(ElementName="ReadingList")]
public class CblReadingList
{
/// <summary>
/// Name of the Reading List
/// </summary>
[XmlElement(ElementName="Name")]
public string Name { get; set; }
[XmlElement(ElementName="Books")]
public CblBooks Books { get; set; }
}

View File

@ -1,4 +1,5 @@
using API.Entities.Enums;
using System;
using API.Entities.Enums;
namespace API.DTOs.ReadingLists;
@ -13,12 +14,28 @@ public class ReadingListItemDto
public int PagesRead { get; set; }
public int PagesTotal { get; set; }
public string ChapterNumber { get; set; }
public string ChapterTitleName { get; set; }
public string VolumeNumber { get; set; }
public int VolumeId { get; set; }
public int LibraryId { get; set; }
public LibraryType LibraryType { get; set; }
public string LibraryName { get; set; }
public string Title { get; set; }
/// <summary>
/// Release Date from Chapter
/// </summary>
public DateTime ReleaseDate { get; set; }
/// <summary>
/// Used internally only
/// </summary>
public int ReadingListId { get; set; }
/// <summary>
/// The last time a reading list item (underlying chapter) was read by current authenticated user
/// </summary>
public DateTime LastReadingProgressUtc { get; set; }
/// <summary>
/// File size of underlying item
/// </summary>
/// <remarks>This is only used for CDisplayEx</remarks>
public long FileSize { get; set; }
}

View File

@ -24,5 +24,13 @@ public class SeriesDetailDto
/// These are chapters that are in Volume 0 and should be read AFTER the volumes
/// </summary>
public IEnumerable<ChapterDto> StorylineChapters { get; set; }
/// <summary>
/// How many chapters are unread
/// </summary>
public int UnreadCount { get; set; }
/// <summary>
/// How many chapters are there
/// </summary>
public int TotalCount { get; set; }
}

View File

@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using System;
using System.ComponentModel.DataAnnotations;
using API.Services;
namespace API.DTOs.Settings;
@ -17,6 +18,10 @@ public class ServerSettingDto
/// </summary>
public int Port { get; set; }
/// <summary>
/// Comma separated list of ip addresses the server listens on. Managed in appsettings.json
/// </summary>
public string IpAddresses { get; set; }
/// <summary>
/// Allows anonymous information to be collected and sent to KavitaStats
/// </summary>
public bool AllowStatCollection { get; set; }
@ -48,10 +53,6 @@ public class ServerSettingDto
/// </summary>
public bool ConvertBookmarkToWebP { get; set; }
/// <summary>
/// If the Swagger UI Should be exposed. Does not require authentication, but does require a JWT.
/// </summary>
public bool EnableSwaggerUi { get; set; }
/// <summary>
/// The amount of Backups before cleanup
/// </summary>
/// <remarks>Value should be between 1 and 30</remarks>
@ -65,4 +66,12 @@ public class ServerSettingDto
/// </summary>
/// <remarks>Value should be between 1 and 30</remarks>
public int TotalLogs { get; set; }
/// <summary>
/// If the server should save covers as WebP encoding
/// </summary>
public bool ConvertCoverToWebP { get; set; }
/// <summary>
/// The Host name (ie Reverse proxy domain name) for the server
/// </summary>
public string HostName { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Statistics;
public class StatCount<T> : ICount<T>
{
public T Value { get; set; }
public long Count { get; set; }
}

View File

@ -0,0 +1,22 @@
using System.Collections.Generic;
using API.Entities.Enums;
namespace API.DTOs.Statistics;
public class FileExtensionDto
{
public string Extension { get; set; }
public MangaFormat Format { get; set; }
public long TotalSize { get; set; }
public long TotalFiles { get; set; }
}
public class FileExtensionBreakdownDto
{
/// <summary>
/// Total bytes for all files
/// </summary>
public long TotalFileSize { get; set; }
public IList<FileExtensionDto> FileBreakdown { get; set; }
}

View File

@ -0,0 +1,7 @@
namespace API.DTOs.Statistics;
public interface ICount<T>
{
public T Value { get; set; }
public long Count { get; set; }
}

View File

@ -0,0 +1,20 @@
using System;
using API.Entities.Enums;
namespace API.DTOs.Statistics;
public class PagesReadOnADayCount<T> : ICount<T>
{
/// <summary>
/// The day of the readings
/// </summary>
public T Value { get; set; }
/// <summary>
/// Number of pages read
/// </summary>
public long Count { get; set; }
/// <summary>
/// Format of those files
/// </summary>
public MangaFormat Format { get; set; }
}

View File

@ -0,0 +1,18 @@
using System;
namespace API.DTOs.Statistics;
/// <summary>
/// Represents a single User's reading event
/// </summary>
public class ReadHistoryEvent
{
public int UserId { get; set; }
public string UserName { get; set; }
public int LibraryId { get; set; }
public int SeriesId { get; set; }
public string SeriesName { get; set; }
public DateTime ReadDate { get; set; }
public int ChapterId { get; set; }
public string ChapterNumber { get; set; }
}

View File

@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace API.DTOs.Statistics;
public class ServerStatisticsDto
{
public long ChapterCount { get; set; }
public long VolumeCount { get; set; }
public long SeriesCount { get; set; }
public long TotalFiles { get; set; }
public long TotalSize { get; set; }
public long TotalGenres { get; set; }
public long TotalTags { get; set; }
public long TotalPeople { get; set; }
public long TotalReadingTime { get; set; }
public IEnumerable<ICount<SeriesDto>> MostReadSeries { get; set; }
/// <summary>
/// Total users who have started/reading/read per series
/// </summary>
public IEnumerable<ICount<SeriesDto>> MostPopularSeries { get; set; }
public IEnumerable<ICount<UserDto>> MostActiveUsers { get; set; }
public IEnumerable<ICount<LibraryDto>> MostActiveLibraries { get; set; }
/// <summary>
/// Last 5 Series read
/// </summary>
public IEnumerable<SeriesDto> RecentlyRead { get; set; }
}

View File

@ -0,0 +1,19 @@
using System.Collections.Generic;
namespace API.DTOs.Statistics;
public class TopReadDto
{
public int UserId { get; set; }
public string Username { get; set; }
/// <summary>
/// Amount of time read on Comic libraries
/// </summary>
public long ComicsTime { get; set; }
/// <summary>
/// Amount of time read on
/// </summary>
public long BooksTime { get; set; }
public long MangaTime { get; set; }
}

View File

@ -0,0 +1,25 @@
using System;
using System.Collections.Generic;
namespace API.DTOs.Statistics;
public class UserReadStatistics
{
/// <summary>
/// Total number of pages read
/// </summary>
public long TotalPagesRead { get; set; }
/// <summary>
/// Total number of words read
/// </summary>
public long TotalWordsRead { get; set; }
/// <summary>
/// Total time spent reading based on estimates
/// </summary>
public long TimeSpentReading { get; set; }
public long ChaptersRead { get; set; }
public DateTime LastActive { get; set; }
public double AvgHoursPerWeekSpentReading { get; set; }
public IEnumerable<StatCount<float>> PercentReadPerLibrary { get; set; }
}

View File

@ -145,4 +145,39 @@ public class ServerInfoDto
/// </summary>
/// <remarks>Introduced in v0.6.0</remarks>
public bool UsingRestrictedProfiles { get; set; }
/// <summary>
/// Number of users using the Emulate Comic Book setting
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public int UsersWithEmulateComicBook { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries with folder watching enabled
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesWithFolderWatchingEnabled { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries included in Search
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesIncludedInSearch { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries included in Recommended
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesIncludedInRecommended { get; set; }
/// <summary>
/// Percent (0.0-1.0) of libraries included in Dashboard
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public float PercentOfLibrariesIncludedInDashboard { get; set; }
/// <summary>
/// Total reading hours of all users
/// </summary>
/// <remarks>Introduced in v0.7.0</remarks>
public long TotalReadingHours { get; set; }
/// <summary>
/// Is the Server saving covers as WebP
/// </summary>
/// <remarks>Added in v0.7.0</remarks>
public bool StoreCoversAsWebP { get; set; }
}

View File

@ -1,5 +1,6 @@
using System;
using API.Entities.Enums.Theme;
using API.Entities.Interfaces;
using API.Services;
namespace API.DTOs.Theme;
@ -7,7 +8,7 @@ namespace API.DTOs.Theme;
/// <summary>
/// Represents a set of css overrides the user can upload to Kavita and will load into webui
/// </summary>
public class SiteThemeDto
public class SiteThemeDto : IEntityDate
{
public int Id { get; set; }
/// <summary>
@ -29,5 +30,7 @@ public class SiteThemeDto
public ThemeProvider Provider { get; set; }
public DateTime Created { get; set; }
public DateTime LastModified { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime LastModifiedUtc { get; set; }
public string Selector => "bg-" + Name.ToLower();
}

View File

@ -1,12 +1,28 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using API.Entities.Enums;
namespace API.DTOs;
public class UpdateLibraryDto
{
[Required]
public int Id { get; init; }
[Required]
public string Name { get; init; }
[Required]
public LibraryType Type { get; set; }
[Required]
public IEnumerable<string> Folders { get; init; }
[Required]
public bool FolderWatching { get; init; }
[Required]
public bool IncludeInDashboard { get; init; }
[Required]
public bool IncludeInRecommended { get; init; }
[Required]
public bool IncludeInSearch { get; init; }
[Required]
public bool CreateCollections { get; init; }
}

View File

@ -37,10 +37,20 @@ public class UserPreferencesDto
[Required]
public LayoutMode LayoutMode { get; set; }
/// <summary>
/// Manga Reader Option: Emulate a book by applying a shadow effect on the pages
/// </summary>
[Required]
public bool EmulateBook { get; set; }
/// <summary>
/// Manga Reader Option: Background color of the reader
/// </summary>
[Required]
public string BackgroundColor { get; set; } = "#000000";
[Required]
/// <summary>
/// Manga Reader Option: Should swiping trigger pagination
/// </summary>
public bool SwipeToPaginate { get; set; }
/// <summary>
/// Manga Reader Option: Allow the menu to close after 6 seconds without interaction
/// </summary>

View File

@ -45,6 +45,7 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
public DbSet<SeriesRelation> SeriesRelation { get; set; }
public DbSet<FolderPath> FolderPath { get; set; }
public DbSet<Device> Device { get; set; }
public DbSet<ServerStatistics> ServerStatistics { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
@ -68,13 +69,15 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
.HasOne(pt => pt.Series)
.WithMany(p => p.Relations)
.HasForeignKey(pt => pt.SeriesId)
.OnDelete(DeleteBehavior.ClientCascade);
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<SeriesRelation>()
.HasOne(pt => pt.TargetSeries)
.WithMany(t => t.RelationOf)
.HasForeignKey(pt => pt.TargetSeriesId)
.OnDelete(DeleteBehavior.ClientCascade);
.OnDelete(DeleteBehavior.Cascade);
builder.Entity<AppUserPreferences>()
@ -87,23 +90,41 @@ public sealed class DataContext : IdentityDbContext<AppUser, AppRole, int,
builder.Entity<AppUserPreferences>()
.Property(b => b.GlobalPageLayoutMode)
.HasDefaultValue(PageLayoutMode.Cards);
builder.Entity<Library>()
.Property(b => b.FolderWatching)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.IncludeInDashboard)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.IncludeInRecommended)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.IncludeInSearch)
.HasDefaultValue(true);
builder.Entity<Library>()
.Property(b => b.ManageCollections)
.HasDefaultValue(true);
}
private static void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IEntityDate entity)
{
entity.Created = DateTime.Now;
entity.LastModified = DateTime.Now;
}
if (e.FromQuery || e.Entry.State != EntityState.Added || e.Entry.Entity is not IEntityDate entity) return;
entity.Created = DateTime.Now;
entity.LastModified = DateTime.Now;
entity.CreatedUtc = DateTime.UtcNow;
entity.LastModifiedUtc = DateTime.UtcNow;
}
private static void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
{
if (e.NewState == EntityState.Modified && e.Entry.Entity is IEntityDate entity)
entity.LastModified = DateTime.Now;
if (e.NewState != EntityState.Modified || e.Entry.Entity is not IEntityDate entity) return;
entity.LastModified = DateTime.Now;
entity.LastModifiedUtc = DateTime.UtcNow;
}
private void OnSaveChanges()

View File

@ -27,7 +27,7 @@ public static class DbFactory
NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(name),
SortName = name,
Volumes = new List<Volume>(),
Metadata = SeriesMetadata(Array.Empty<CollectionTag>())
Metadata = SeriesMetadata(new List<CollectionTag>())
};
}
@ -46,7 +46,7 @@ public static class DbFactory
NormalizedLocalizedName = Services.Tasks.Scanner.Parser.Parser.Normalize(localizedName),
SortName = name,
Volumes = new List<Volume>(),
Metadata = SeriesMetadata(Array.Empty<CollectionTag>())
Metadata = SeriesMetadata(new List<CollectionTag>())
};
}
@ -76,11 +76,6 @@ public static class DbFactory
};
}
public static SeriesMetadata SeriesMetadata(ComicInfo info)
{
return SeriesMetadata(Array.Empty<CollectionTag>());
}
public static SeriesMetadata SeriesMetadata(ICollection<CollectionTag> collectionTags)
{
return new SeriesMetadata()
@ -95,10 +90,11 @@ public static class DbFactory
return new CollectionTag()
{
Id = id,
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(),
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()),
Title = title?.Trim(),
Summary = summary?.Trim(),
Promoted = promoted
Promoted = promoted,
SeriesMetadatas = new List<SeriesMetadata>()
};
}
@ -106,7 +102,7 @@ public static class DbFactory
{
return new ReadingList()
{
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()).ToUpper(),
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(title?.Trim()),
Title = title?.Trim(),
Summary = summary?.Trim(),
Promoted = promoted,
@ -125,23 +121,21 @@ public static class DbFactory
};
}
public static Genre Genre(string name, bool external)
public static Genre Genre(string name)
{
return new Genre()
{
Title = name.Trim().SentenceCase(),
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name),
ExternalTag = external
};
}
public static Tag Tag(string name, bool external)
public static Tag Tag(string name)
{
return new Tag()
{
Title = name.Trim().SentenceCase(),
NormalizedTitle = Services.Tasks.Scanner.Parser.Parser.Normalize(name),
ExternalTag = external
};
}
@ -162,7 +156,8 @@ public static class DbFactory
FilePath = filePath,
Format = format,
Pages = pages,
LastModified = File.GetLastWriteTime(filePath)
LastModified = File.GetLastWriteTime(filePath),
LastModifiedUtc = File.GetLastWriteTimeUtc(filePath),
};
}

View File

@ -1,5 +1,6 @@
using System;
using System.Linq;
using API.Entities;
using API.Entities.Enums;
using Kavita.Common.Extensions;
@ -54,13 +55,27 @@ public class ComicInfo
/// User's rating of the content
/// </summary>
public float UserRating { get; set; }
public string StoryArc { get; set; } = string.Empty;
/// <summary>
/// Can contain multiple comma separated strings, each create a <see cref="CollectionTag"/>
/// </summary>
public string SeriesGroup { get; set; } = string.Empty;
/// <summary>
///
/// </summary>
public string StoryArc { get; set; } = string.Empty;
/// <summary>
/// Can contain multiple comma separated numbers that match with StoryArc
/// </summary>
public string StoryArcNumber { get; set; } = string.Empty;
public string AlternateNumber { get; set; } = string.Empty;
public string AlternateSeries { get; set; } = string.Empty;
/// <summary>
/// Not used
/// </summary>
[System.ComponentModel.DefaultValueAttribute(0)]
public int AlternateCount { get; set; } = 0;
public string AlternateSeries { get; set; } = string.Empty;
/// <summary>
/// This is Epub only: calibre:title_sort

View File

@ -20,7 +20,7 @@ public static class MigrateChangePasswordRoles
var usersWithRole = await userManager.GetUsersInRoleAsync(PolicyConstants.ChangePasswordRole);
if (usersWithRole.Count != 0) return;
var allUsers = await unitOfWork.UserRepository.GetAllUsers();
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in allUsers)
{
await userManager.RemoveFromRoleAsync(user, "ChangePassword");

View File

@ -24,7 +24,7 @@ public static class MigrateChangeRestrictionRoles
logger.LogCritical("Running MigrateChangeRestrictionRoles migration");
var allUsers = await unitOfWork.UserRepository.GetAllUsers();
var allUsers = await unitOfWork.UserRepository.GetAllUsersAsync();
foreach (var user in allUsers)
{
await userManager.RemoveFromRoleAsync(user, PolicyConstants.ChangeRestrictionRole);

View File

@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using API.Entities.Enums;
using CsvHelper;
using Kavita.Common.EnvironmentInfo;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
internal sealed class SeriesRelationMigrationOutput
{
public string SeriesName { get; set; }
public int SeriesId { get; set; }
public string TargetSeriesName { get; set; }
public int TargetId { get; set; }
public RelationKind Relationship { get; set; }
}
/// <summary>
/// Introduced in v0.6.1.2 and v0.7, this exports to a temp file the existing series relationships. It is a 3 part migration.
/// This will run first, to export the data, then the DB migration will change the way the DB is constructed, then the last migration
/// will import said file and re-construct the relationships.
/// </summary>
public static class MigrateSeriesRelationsExport
{
private const string OutputFile = "config/relations.csv";
private const string CompleteOutputFile = "config/relations-imported.csv";
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateSeriesRelationsExport migration - Please be patient, this may take some time. This is not an error");
if (BuildInfo.Version > new Version(0, 6, 1, 3)
|| new FileInfo(OutputFile).Exists
|| new FileInfo(CompleteOutputFile).Exists)
{
logger.LogCritical("Running MigrateSeriesRelationsExport migration - complete. Nothing to do");
return;
}
var seriesWithRelationships = await dataContext.Series
.Where(s => s.Relations.Any())
.Include(s => s.Relations)
.ThenInclude(r => r.TargetSeries)
.ToListAsync();
var records = new List<SeriesRelationMigrationOutput>();
var excludedRelationships = new List<RelationKind>()
{
RelationKind.Parent,
};
foreach (var series in seriesWithRelationships)
{
foreach (var relationship in series.Relations.Where(r => !excludedRelationships.Contains(r.RelationKind)))
{
records.Add(new SeriesRelationMigrationOutput()
{
SeriesId = series.Id,
SeriesName = series.Name,
Relationship = relationship.RelationKind,
TargetId = relationship.TargetSeriesId,
TargetSeriesName = relationship.TargetSeries.Name
});
}
}
await using var writer = new StreamWriter(OutputFile);
await using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
await csv.WriteRecordsAsync(records);
}
await writer.DisposeAsync();
logger.LogCritical("{OutputFile} has a backup of all data", OutputFile);
logger.LogCritical("Deleting all relationships in the DB. This is not an error");
var entities = await dataContext.SeriesRelation
.Include(s => s.Series)
.Include(s => s.TargetSeries)
.Select(s => s)
.ToListAsync();
foreach (var seriesWithRelationship in entities)
{
logger.LogCritical("Deleting {SeriesName} --{RelationshipKind}--> {TargetSeriesName}",
seriesWithRelationship.Series.Name, seriesWithRelationship.RelationKind, seriesWithRelationship.TargetSeries.Name);
dataContext.SeriesRelation.Remove(seriesWithRelationship);
await dataContext.SaveChangesAsync();
}
// In case of corrupted entities (where series were deleted but their Id still existed, we delete the rest of the table)
dataContext.SeriesRelation.RemoveRange(dataContext.SeriesRelation);
await dataContext.SaveChangesAsync();
logger.LogCritical("Running MigrateSeriesRelationsExport migration - Completed. This is not an error");
}
}

View File

@ -0,0 +1,64 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Entities.Metadata;
using CsvHelper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Introduced in v0.6.1.2 and v0.7, this imports to a temp file the existing series relationships. It is a 3 part migration.
/// This will run last, to import the data and re-construct the relationships.
/// </summary>
public static class MigrateSeriesRelationsImport
{
private const string OutputFile = "config/relations.csv";
private const string CompleteOutputFile = "config/relations-imported.csv";
public static async Task Migrate(DataContext dataContext, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateSeriesRelationsImport migration - Please be patient, this may take some time. This is not an error");
if (!new FileInfo(OutputFile).Exists)
{
logger.LogCritical("Running MigrateSeriesRelationsImport migration - complete. Nothing to do");
return;
}
logger.LogCritical("Loading backed up relationships into the DB");
List<SeriesRelationMigrationOutput> records;
using var reader = new StreamReader(OutputFile);
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
records = csv.GetRecords<SeriesRelationMigrationOutput>().ToList();
}
foreach (var relation in records)
{
logger.LogCritical("Importing {SeriesName} --{RelationshipKind}--> {TargetSeriesName}",
relation.SeriesName, relation.Relationship, relation.TargetSeriesName);
// Filter out series that don't exist
if (!await dataContext.Series.AnyAsync(s => s.Id == relation.SeriesId) ||
!await dataContext.Series.AnyAsync(s => s.Id == relation.TargetId))
continue;
await dataContext.SeriesRelation.AddAsync(new SeriesRelation()
{
SeriesId = relation.SeriesId,
TargetSeriesId = relation.TargetId,
RelationKind = relation.Relationship
});
}
await dataContext.SaveChangesAsync();
File.Move(OutputFile, CompleteOutputFile);
logger.LogCritical("Running MigrateSeriesRelationsImport migration - Completed. This is not an error");
}
}

View File

@ -0,0 +1,153 @@
using System;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Introduced in v0.6.1.38 or v0.7.0,
/// </summary>
public static class MigrateToUtcDates
{
public static async Task Migrate(IUnitOfWork unitOfWork, DataContext dataContext, ILogger<Program> logger)
{
// if current version is > 0.6.1.38, then we can exit and not perform
var settings = await unitOfWork.SettingsRepository.GetSettingsDtoAsync();
if (Version.Parse(settings.InstallVersion) > new Version(0, 6, 1, 38))
{
return;
}
logger.LogCritical("Running MigrateToUtcDates migration. Please be patient, this may take some time depending on the size of your library. Do not abort, this can break your Database");
#region Series
logger.LogInformation("Updating Dates on Series...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE Series SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc'),
[LastChapterAddedUtc] = datetime([LastChapterAdded], 'utc'),
[LastFolderScannedUtc] = datetime([LastFolderScanned], 'utc')
;
");
logger.LogInformation("Updating Dates on Series...Done");
#endregion
#region Library
logger.LogInformation("Updating Dates on Libraries...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE Library SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc')
;
");
logger.LogInformation("Updating Dates on Libraries...Done");
#endregion
#region Volume
try
{
logger.LogInformation("Updating Dates on Volumes...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE Volume SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc');
");
logger.LogInformation("Updating Dates on Volumes...Done");
}
catch (Exception ex)
{
logger.LogCritical(ex, "Updating Dates on Volumes...Failed");
}
#endregion
#region Chapter
try
{
logger.LogInformation("Updating Dates on Chapters...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE Chapter SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc')
;
");
logger.LogInformation("Updating Dates on Chapters...Done");
}
catch (Exception ex)
{
logger.LogCritical(ex, "Updating Dates on Chapters...Failed");
}
#endregion
#region AppUserBookmark
logger.LogInformation("Updating Dates on Bookmarks...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE AppUserBookmark SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc')
;
");
logger.LogInformation("Updating Dates on Bookmarks...Done");
#endregion
#region AppUserProgress
logger.LogInformation("Updating Dates on Progress...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE AppUserProgresses SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc')
;
");
logger.LogInformation("Updating Dates on Progress...Done");
#endregion
#region Device
logger.LogInformation("Updating Dates on Device...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE Device SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc'),
[LastUsedUtc] = datetime([LastUsed], 'utc')
;
");
logger.LogInformation("Updating Dates on Device...Done");
#endregion
#region MangaFile
logger.LogInformation("Updating Dates on MangaFile...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE MangaFile SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc'),
[LastFileAnalysisUtc] = datetime([LastFileAnalysis], 'utc')
;
");
logger.LogInformation("Updating Dates on MangaFile...Done");
#endregion
#region ReadingList
logger.LogInformation("Updating Dates on ReadingList...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE ReadingList SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc')
;
");
logger.LogInformation("Updating Dates on ReadingList...Done");
#endregion
#region SiteTheme
logger.LogInformation("Updating Dates on SiteTheme...");
await dataContext.Database.ExecuteSqlRawAsync(@"
UPDATE SiteTheme SET
[LastModifiedUtc] = datetime([LastModified], 'utc'),
[CreatedUtc] = datetime([Created], 'utc')
;
");
logger.LogInformation("Updating Dates on SiteTheme...Done");
#endregion
logger.LogInformation("MigrateToUtcDates migration finished");
}
}

View File

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using API.Entities.Enums;
using API.Entities.Metadata;
using CsvHelper;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace API.Data;
/// <summary>
/// Introduced in v0.6.1.8 and v0.7, this adds library ids to all User Progress to allow for easier queries against progress
/// </summary>
public static class MigrateUserProgressLibraryId
{
public static async Task Migrate(IUnitOfWork unitOfWork, ILogger<Program> logger)
{
logger.LogCritical("Running MigrateUserProgressLibraryId migration - Please be patient, this may take some time. This is not an error");
var progress = await unitOfWork.AppUserProgressRepository.GetAnyProgress();
if (progress == null || progress.LibraryId != 0)
{
logger.LogCritical("Running MigrateUserProgressLibraryId migration - complete. Nothing to do");
return;
}
var seriesIdsWithLibraryIds = await unitOfWork.SeriesRepository.GetLibraryIdsForSeriesAsync();
foreach (var prog in await unitOfWork.AppUserProgressRepository.GetAllProgress())
{
prog.LibraryId = seriesIdsWithLibraryIds[prog.SeriesId];
unitOfWork.AppUserProgressRepository.Update(prog);
}
await unitOfWork.CommitAsync();
logger.LogCritical("Running MigrateSeriesRelationsImport migration - Completed. This is not an error");
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,61 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class SeriesRelationChange : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_SeriesRelation_Series_SeriesId",
table: "SeriesRelation");
migrationBuilder.DropForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation");
migrationBuilder.AddForeignKey(
name: "FK_SeriesRelation_Series_SeriesId",
table: "SeriesRelation",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation",
column: "TargetSeriesId",
principalTable: "Series",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_SeriesRelation_Series_SeriesId",
table: "SeriesRelation");
migrationBuilder.DropForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation");
migrationBuilder.AddForeignKey(
name: "FK_SeriesRelation_Series_SeriesId",
table: "SeriesRelation",
column: "SeriesId",
principalTable: "Series",
principalColumn: "Id");
migrationBuilder.AddForeignKey(
name: "FK_SeriesRelation_Series_TargetSeriesId",
table: "SeriesRelation",
column: "TargetSeriesId",
principalTable: "Series",
principalColumn: "Id");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,59 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class ExtendedLibrarySettings : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "FolderWatching",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInDashboard",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInRecommended",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<bool>(
name: "IncludeInSearch",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FolderWatching",
table: "Library");
migrationBuilder.DropColumn(
name: "IncludeInDashboard",
table: "Library");
migrationBuilder.DropColumn(
name: "IncludeInRecommended",
table: "Library");
migrationBuilder.DropColumn(
name: "IncludeInSearch",
table: "Library");
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class FileLengthAndExtension : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "Bytes",
table: "MangaFile",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<string>(
name: "Extension",
table: "MangaFile",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Bytes",
table: "MangaFile");
migrationBuilder.DropColumn(
name: "Extension",
table: "MangaFile");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class YearlyStats : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ServerStatistics",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Year = table.Column<int>(type: "INTEGER", nullable: false),
SeriesCount = table.Column<long>(type: "INTEGER", nullable: false),
VolumeCount = table.Column<long>(type: "INTEGER", nullable: false),
ChapterCount = table.Column<long>(type: "INTEGER", nullable: false),
FileCount = table.Column<long>(type: "INTEGER", nullable: false),
UserCount = table.Column<long>(type: "INTEGER", nullable: false),
GenreCount = table.Column<long>(type: "INTEGER", nullable: false),
PersonCount = table.Column<long>(type: "INTEGER", nullable: false),
TagCount = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ServerStatistics", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ServerStatistics");
}
}
}

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,36 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace API.Data.Migrations
{
public partial class AutoCollections : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "ManageCollections",
table: "Library",
type: "INTEGER",
nullable: false,
defaultValue: true);
migrationBuilder.AddColumn<string>(
name: "SeriesGroup",
table: "Chapter",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ManageCollections",
table: "Library");
migrationBuilder.DropColumn(
name: "SeriesGroup",
table: "Chapter");
}
}
}

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