Compare commits

...

674 Commits

Author SHA1 Message Date
IRHM 0730a99df4 auth: use secret from config 2024-10-06 19:39:32 +01:00
IRHM 46a043e5b9 Fix .small-scrollbar style for chromium
.small-scrollbar is used on poster rating/status lists, but wasn't working because we also have the `scrollbar-width` property set, so now if the browser supports the webkit scrollbar styling, we will prefer that over scrollbar-width.

Don't know if this will break safari or whatever but hell if ima download that.

firefox scrollbar looks nice and this will let us keep using scrollbar-width: small wherever we want for that sweet smol bar action in ff
2024-10-04 03:05:51 +01:00
IRHM 87689b201e Poster: Let title/overview overflow with scroll 2024-10-04 02:24:02 +01:00
Mr 8d34513ab7 Merge pull request #647 from sbondCo/fix-poster-focus
Fix poster focus/accessibility issues and middle clicking
2024-10-03 23:21:04 +00:00
IRHM ed9a9aae7e Poster: fix being re-focused after window regain focus 2024-10-04 00:11:09 +01:00
IRHM 48814c213a Poster: Fix issues i made 11 days ago 2024-10-03 23:27:04 +01:00
IRHM 5a70b8a240 Posters: Fix mouseleave from rating/status menus causing extraDetails to show
hopefully they were left here by accident
2024-09-22 22:08:29 +01:00
IRHM 31ce74545a Posters: Fix mobile tab and hold link 2024-09-22 21:08:53 +01:00
IRHM 2b78581062 Posters: Fix keyboard focus not hiding ExtraDetails
Focus events now do same thing and mouse enter/leave (focusin/out), so we have the same behaviour.
2024-09-22 20:49:04 +01:00
IRHM 6d5aa0feff Poster: Set tabindex to -1 for inner container to avoid extra element to highlight when tabbing through
The inner container is not helpful for highlighting when tabbing through posters. The title of poster can still be used to enter the content via keyboard, the inner container did the same thing so was redundant and only added an extra click for any navigating via keyboard.
2024-09-22 20:39:28 +01:00
IRHM 7cfbc6c344 Posters: Fix focus style (only apply if focused via keyboard) 2024-09-22 20:37:49 +01:00
Mr 64e7929714 Merge pull request #636 from sbondCo/fix-tagmenu-oob
Fix tagmenu oob
2024-09-22 12:55:27 +00:00
IRHM b2f817b639 TagMenu: Use stayInView action, use an element for arrow
TagMenu uses an element for the arrow now so the stayInView action can control its position.
2024-09-22 13:48:52 +01:00
IRHM 7a3e5f4354 norm.scss: .menu support using an element with .arrow class instead of ::before pseudoelement when more control is needed
more control = need to be able to move it in js.

Still supports using ::before, so not all menus need updating and can stay simple, just the menus needing more control can start using an actual element for the arrow.
2024-09-22 13:46:50 +01:00
IRHM f033a50632 stayInView: Make logs debug scope, fix log in update func 2024-09-22 13:44:30 +01:00
IRHM e3ace9f2fe Create stayInView action
Currently will only shift elements back into view if out of bounds on the left.

Not necessary at the moment to support oob on other sides, or support resizing to fit. Maybe in the future when it's used more.
2024-09-22 13:43:09 +01:00
dependabot[bot] f07c2c797b ui: bump prettier-plugin-svelte from 3.2.3 to 3.2.6 (#616)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.2.3 to 3.2.6.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.2.3...v3.2.6)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:16:09 +00:00
dependabot[bot] 1a8da9137a ui: bump svelte-check from 3.8.6 to 4.0.2 (#628)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.8.6 to 4.0.2.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.8.6...svelte-check-4.0.2)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:14:02 +00:00
dependabot[bot] 2476833a19 server: bump golang.org/x/crypto from 0.26.0 to 0.27.0 in /server (#624)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.26.0 to 0.27.0.
- [Commits](https://github.com/golang/crypto/compare/v0.26.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:11:58 +00:00
dependabot[bot] 9f89d2101c ui: bump @sveltejs/adapter-node from 5.0.1 to 5.2.3 (#635)
Bumps [@sveltejs/adapter-node](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-node) from 5.0.1 to 5.2.3.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-node/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-node@5.2.3/packages/adapter-node)

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:09:27 +00:00
dependabot[bot] c3db5054b4 server: bump gorm.io/gorm from 1.25.11 to 1.25.12 in /server (#623)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.11 to 1.25.12.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.11...v1.25.12)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:07:36 +00:00
dependabot[bot] f96562e2df ui: bump @sveltejs/kit from 2.5.24 to 2.5.27 (#629)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.24 to 2.5.27.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.5.27/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:05:37 +00:00
dependabot[bot] 22163950ef ui: bump vite from 5.2.11 to 5.4.6 (#631)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.11 to 5.4.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 22:04:18 +00:00
Mr c80d5cabcb Add format_check_go job (#634)
* test-pr.yml: Add format_check_go job

* separate test-pr into two files for client and server

so we can set the `paths` property and make the jobs only run when changes are detected for the client or server

* test breaking formatting

* correct formatting

* test-pr-server.yml: uncomment yml path

test has succeeded
2024-09-19 21:59:04 +00:00
Mr d3cfc9258f Merge pull request #633 from sbondCo/fix-no-filtering-tag-page
Fix no filtering tag page and other stuffs i found
2024-09-19 01:29:02 +00:00
IRHM 74ff9f04dd FilterMenu: Remove array join from class name on clear button 2024-09-19 02:25:32 +01:00
IRHM 28f13836a1 WatchedList: Clear Filters btn when filters hiding all results 2024-09-19 01:53:21 +01:00
IRHM 242e70c9ec store: fix clear filters func name
y did me name features ;-()
2024-09-19 01:52:57 +01:00
IRHM 4bf2e0b376 FilterMenu: Keep height same with and without clear btn 2024-09-19 01:47:50 +01:00
IRHM 144244bd5e TagMenu: Fix title text and a comment in deleteTag() 2024-09-19 01:44:15 +01:00
IRHM 473f8f6319 FilterMenu: Clear filters button 2024-09-19 01:43:52 +01:00
IRHM b86ab4229f Icon: Fix ticket and filter-circle colors 2024-09-19 01:39:24 +01:00
IRHM 8f20589faa Show filter/sort btns on tag page 2024-09-19 01:03:16 +01:00
IRHM 33c2000182 Tag: Make empty tag page error nicer
Added icon, consistent font sizes to watched list empty error
2024-09-19 01:02:33 +01:00
IRHM 0505d265fe WatchedList: Improve empty list error to take active filters into account 2024-09-19 01:00:57 +01:00
Mr f8e5a668f8 Merge pull request #618 from AlexPerathoner/dev
Import: add todomovies
2024-09-16 23:52:38 +00:00
IRHM 447ee2e82f import/process: Add status table header and fix status dropdown PLANNED option
Status table header was missing, corrected dropdown value from PLANNING to PLANNED
2024-09-17 00:21:57 +01:00
IRHM fe7730dbcc tags: Rename getTag to getTagByNameAndColor
and formatted code, updated some logs
2024-09-16 23:58:13 +01:00
IRHM 09ac804730 import: Move trakt import button higher
Probably a more famous import option, so moving it a bit higher in the list
2024-09-16 23:57:19 +01:00
IRHM 0f24623384 Icon: Fix todomovies icon
simplified and removed fill so styling can color it
2024-09-16 23:56:37 +01:00
Mr 964c71382a Merge pull request #627 from sbondCo/new-rating-systems
New rating systems
2024-09-15 21:14:29 +00:00
IRHM 65f8f67e4d Rating: Remove debug text 2024-09-15 21:36:20 +01:00
IRHM 8b92d42634 RatingSetting: Allow overflow
Allow scrolling if too smol
2024-09-15 21:30:15 +01:00
IRHM 1cab95370b StarRating: Fix firefox mobile clicks not saving rating and the jitter after selecting a rating
We now wait for the save request to complete before unfocusing the stars, which avoids the jitter where it snaps back to actual rating before snapping to new rating when it finally saves
2024-09-15 21:16:28 +01:00
IRHM b5fac676e9 StarRating: Remove tap highlight 2024-09-15 18:36:53 +01:00
IRHM b67b0acc9a toShowableRating: Round OutOf5 Point1 result 2024-09-15 18:27:15 +01:00
IRHM c2fe4486d9 StarRating: Save selected rating on touchEnd 2024-09-15 18:21:46 +01:00
IRHM 88fea6a8bd StarRating: Fix oneStarWidth
Is smaller when the media query makes the letter-spacing smaller. If i need to change this again, might be better to get it dynamically, but on component load and window resize instead of in each func call?
2024-09-15 16:13:39 +01:00
IRHM 6b64aa0d89 StarRating: Off right check better 2024-09-15 16:02:11 +01:00
IRHM df3955cfe1 Rating: Make more responsive 2024-09-15 15:38:31 +01:00
IRHM 8359be7bee StarRating: Extra checks to keep text on screen 2024-09-15 15:23:21 +01:00
IRHM 15f4a98ccb StarRating: Fix rating texts being one off or completely wrong for OutOf5 2024-09-15 02:58:13 +01:00
IRHM 02e9654791 ExtraDetails now a component and supports rating scaling
- Moved ExtraDetails (from Poster and GamePoster) to its own component for reuse, also no longer one line.
- Consolodated ExtraDetails and ExtraDetailsGame types into one type: PosterExtraDetails.
- Created Rating helpers lib. Contains: toShowableRating() and toWhichThumb()
- Fix extra detail dates not listing for games on search page
2024-09-14 23:31:44 +01:00
IRHM 8fd570ad5c StarRating: Restore the longer 100ms transition for rating text 2024-09-14 19:52:50 +01:00
IRHM bd10d6854a StarRating: Fix centering issue in moveRatingText func
Instead of trying to center the rating text element ourselves by using its width/2, we can just simply rely on the tranlateX -50% style instead of removing it.
2024-09-14 19:50:23 +01:00
IRHM 6249134dd6 StarRating: Fix showing hoveredRating when 0 2024-09-10 01:44:09 +01:00
IRHM 5d425cadd6 StarRating: Comment log 2024-09-09 01:24:04 +01:00
IRHM 695015b481 PosterRating: Separate rating popup for minimal usage
Used for Season/Episode ratings.... probably should separate that out or support the rating systems there in the future
2024-09-09 01:21:09 +01:00
IRHM 8d81b96692 poster ratings 2024-09-09 01:10:04 +01:00
IRHM 0bc9aa612c StarRating: Fix shown ratings
Should work in all cases when swapping between star systems/steps now
2024-09-06 01:26:48 +01:00
IRHM 968212e189 StarRating: Show ratingText with svelte
Fixes responsive var issues where the value of hoveredRating was for the old one
2024-09-06 01:05:56 +01:00
IRHM 4ab2267867 StarRating: Show rating in ratingText for .1 increments 2024-09-06 00:40:30 +01:00
IRHM 2cfe39f276 StarRating: Wrap try/catch around moveRatingText 2024-09-06 00:34:24 +01:00
IRHM a155e1eeea StarRating: Get ratingText working again 2024-09-06 00:31:24 +01:00
IRHM 81299f30de StarRating: Scale rating before saving 2024-09-06 00:03:23 +01:00
IRHM 17349e8367 ThumbRating: Show rating and save 2024-09-05 23:49:55 +01:00
IRHM f2faad3886 Save rating settings, separate StarRating to its own component, create ThumRating component, RatingStep is now an enum to avoid using decimal value on server 2024-09-05 23:41:39 +01:00
IRHM 451a5923d4 rating now float64
Seems to actually be accurate for what we need, I think we should just use float64 instead of importing a library for decimal values (if it continues working and i find no more issues)
2024-09-05 16:18:36 +01:00
Alex Perathoner 5e3ed9ceed import: add todomovies
Added:
- button in /import
- process of .todomovieslist files
- tags from import
2024-09-03 16:39:56 +02:00
IRHM 57f29c054a Rating: Fix rating being reset before being saved
Because of a bug with mouseleave tirggering when a star is clicked directly and browser focused at same time.

Also renamed handleStarHoverEnd->handleRatingHoverEnd since its now on the rating wrap element instead of a star element
2024-09-03 00:45:32 +01:00
IRHM be10df1c57 Rating: Uncomment save rating code 2024-09-03 00:44:25 +01:00
IRHM baba2fad4d Rating: Keyboard support, fix type errors, moved things around, some cleanup 2024-09-03 00:18:01 +01:00
IRHM bad4f505ef Watched: Rating now a float32 instead of int8
The only backwards compatible way to support the new rating systems is to turn ratings into a float, still max 10.
2024-09-03 00:16:47 +01:00
IRHM 4390f02f4d WIP: New Rating System
Most of the client work is done:
- Added new RatingSystem and RatingStep settings
- Rating component now supports all systems and steps (minus thumbs)

Thumbs and server work for saving user setting and then testing! yupee
2024-09-01 20:57:30 +01:00
dependabot[bot] a790e0d4e0 ui: bump svelte from 4.2.17 to 4.2.19 (#611)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.17 to 4.2.19.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.19/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.19/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-31 16:26:49 +00:00
IRHM 3ccd983a5e v1.43.0 2024-08-30 01:00:21 +01:00
IRHM 760de9dead 1.43.0-dev 2024-08-30 00:42:10 +01:00
Mr b997b421fa Preload last season viewed (#609)
* Save requested season number in `LastViewedSeason` property

When season details are requested and a watchedId is passed as a query parameter, the requested season number is now saved in the watched entries `LastViewedSeason` property.

* SeasonsList: Restore activeSeason and update state after request with new lastViewedSeason value

* get season details route: add extra log for else case where no watchedId is found
2024-08-29 01:53:05 +00:00
Mr 208a66f303 person: Fix credits not changing if nav between two people (#608) 2024-08-26 21:04:40 +00:00
Mr d431f4a434 page titles (profile, content, person) (#607)
* page titles (profile, content, person)

* person: remove log
2024-08-26 20:47:23 +00:00
dependabot[bot] b571239312 ui: bump svelte-check from 3.8.5 to 3.8.6 (#604)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.8.5 to 3.8.6.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.8.5...svelte-check-3.8.6)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:17:06 +00:00
dependabot[bot] 126717ecdc ui: bump tslib from 2.6.2 to 2.7.0 (#605)
Bumps [tslib](https://github.com/Microsoft/tslib) from 2.6.2 to 2.7.0.
- [Release notes](https://github.com/Microsoft/tslib/releases)
- [Commits](https://github.com/Microsoft/tslib/compare/v2.6.2...v2.7.0)

---
updated-dependencies:
- dependency-name: tslib
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:16:42 +00:00
dependabot[bot] 374fc59499 ui: bump eslint-plugin-svelte from 2.41.0 to 2.43.0 (#603)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.41.0 to 2.43.0.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/eslint-plugin-svelte@2.41.0...eslint-plugin-svelte@2.43.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:16:28 +00:00
dependabot[bot] c8db3093be ui: bump svelte-eslint-parser from 0.39.2 to 0.41.0 (#606)
Bumps [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser) from 0.39.2 to 0.41.0.
- [Release notes](https://github.com/sveltejs/svelte-eslint-parser/releases)
- [Changelog](https://github.com/sveltejs/svelte-eslint-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-eslint-parser/compare/v0.39.2...v0.41.0)

---
updated-dependencies:
- dependency-name: svelte-eslint-parser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-26 20:14:13 +00:00
IRHM e182799ca9 v1.42.0 2024-08-25 23:33:16 +01:00
IRHM 870f2614b5 Import: fix trakt button colors for dark theme 2024-08-25 23:25:10 +01:00
IRHM 0e88f40a99 Tag: Fix icon color for dark theme 2024-08-25 23:22:21 +01:00
IRHM faead2ebd3 TagMenu: Move menu more to left 2024-08-25 23:17:31 +01:00
IRHM 416098b53d 1.42.0-dev1 2024-08-25 21:54:22 +01:00
IRHM 73c756a780 doc/README: Include link to live site 2024-08-25 21:41:33 +01:00
dependabot[bot] b7d261bad4 server: bump github.com/go-co-op/gocron/v2 in /server (#587)
Bumps [github.com/go-co-op/gocron/v2](https://github.com/go-co-op/gocron) from 2.7.0 to 2.11.0.
- [Release notes](https://github.com/go-co-op/gocron/releases)
- [Commits](https://github.com/go-co-op/gocron/compare/v2.7.0...v2.11.0)

---
updated-dependencies:
- dependency-name: github.com/go-co-op/gocron/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 19:50:02 +00:00
dependabot[bot] c8203187a7 server: bump gorm.io/gorm from 1.25.10 to 1.25.11 in /server (#584)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.10 to 1.25.11.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.10...v1.25.11)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 15:03:07 +00:00
dependabot[bot] 3014019692 ui: bump svelte-preprocess from 5.1.4 to 6.0.2 (#586)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.4 to 6.0.2.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.4...v6.0.2)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 01:02:01 +00:00
dependabot[bot] 9e2f60fa56 ui: bump typescript from 5.4.5 to 5.5.4 (#588)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.5 to 5.5.4.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.5...v5.5.4)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:56:54 +00:00
dependabot[bot] 7642655ef0 ui: bump svelte-check from 3.7.1 to 3.8.5 (#590)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.7.1 to 3.8.5.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.7.1...svelte-check-3.8.5)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:55:45 +00:00
dependabot[bot] a28d199431 ui: bump axios from 1.7.2 to 1.7.4 (#600)
Bumps [axios](https://github.com/axios/axios) from 1.7.2 to 1.7.4.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.7.2...v1.7.4)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:53:07 +00:00
dependabot[bot] cf387d1c0a server: bump golang.org/x/crypto from 0.24.0 to 0.26.0 in /server (#593)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.24.0 to 0.26.0.
- [Commits](https://github.com/golang/crypto/compare/v0.24.0...v0.26.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:52:20 +00:00
dependabot[bot] 64f31a234e ui: bump @sveltejs/kit from 2.5.10 to 2.5.24 (#596)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.10 to 2.5.24.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.5.24/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-08-25 00:49:19 +00:00
Mr 09d1c513d6 api: Use location to create baseURL var in dev mode (#599)
Makes it easier when accessing the vite dev server from outside the local machine, instead of making me change the variable to my machines local ip all the time
2024-08-25 00:46:01 +00:00
Mr 89f703ae9b Close all sub menus on nav bar after a navigation (#598) 2024-08-25 00:34:19 +00:00
Mr ae09de6f71 Merge pull request #597 from sbondCo/tags
Tags
2024-08-25 00:28:37 +00:00
IRHM 7c39a0649a Tag: remove unused css 2024-08-25 01:21:48 +01:00
IRHM 035d68a103 Add AddToTagButton to Movies and Games pages 2024-08-25 01:02:29 +01:00
IRHM 3b501e73c8 Tag: Add subtitle with some more info on empty tag text 2024-08-25 00:55:55 +01:00
IRHM d7353fc2a7 Support editing tags
by clicking on the tag in the tag page.. not very obvious, may need some way to make it so
2024-08-25 00:52:15 +01:00
IRHM c65579c0f6 Nav: Close tagMenu when tag clicked 2024-08-25 00:19:00 +01:00
IRHM 0a886f0062 DeleteTagModal: Also remove tag from all watched entries 2024-08-25 00:14:50 +01:00
IRHM a347e6dc97 Finish DeleteTagModal 2024-08-25 00:09:04 +01:00
IRHM a9088c9626 wip: Tag deletion (route working, modal nearly done) 2024-08-20 17:27:07 +01:00
IRHM a19d61a16d Tag: Show tag icon instead of 'viewing tag' text 2024-08-20 15:51:00 +01:00
IRHM 003c8e724c Tag: Add 'viewing tag:' text 2024-08-20 15:47:37 +01:00
IRHM 053fc28371 Fix nav input responsiveness 2024-08-20 15:47:23 +01:00
IRHM 9d8ae819a7 Fix nav menu positions 2024-08-20 15:36:54 +01:00
IRHM 658b9f356c Tag: not exist errors 2024-08-19 16:18:25 +01:00
IRHM becf9dd601 basic tag view page 2024-08-19 16:07:19 +01:00
IRHM b74ecbddf7 AddToTagButton: Close menu on scroll 2024-08-19 14:36:47 +01:00
IRHM 38e52bd821 Tagging and untagging watched content working 2024-08-18 20:25:36 +01:00
IRHM c3e08b2dc0 watched: getWatched: Preload Tags and format each call in chain to newline 2024-08-17 18:15:51 +01:00
IRHM f0019b540a OnTagClick 2024-08-17 18:15:20 +01:00
IRHM b0a72bf636 tag watched route 2024-08-17 00:43:17 +01:00
IRHM 1701dd560e Make TagMenu more reusable, create AddToTagButton
Started working on adding content to tag button (from the content page), will likely have to have other ways of adding content to a tag
2024-08-16 23:20:43 +01:00
IRHM 7ddb880843 Tag creation working
- New Tag component (to display a tag button)
- Modified tag color presets to be more readable
2024-08-14 00:13:42 +01:00
IRHM f0c929d743 Modal: New error property
Just simplifies what we have been doing in other places where we use the  Modal component; making an error span at the top.
2024-08-14 00:12:16 +01:00
IRHM a7fc789056 Basic tag creation modal, color selector 2024-08-11 23:31:43 +01:00
IRHM 11cf23acb8 Start work on tags 2024-08-11 00:07:23 +01:00
IRHM c05c271e1b Dockerfile: update backend image to golang:1.22-alpine
becuase we use go 1.22 now
2024-07-13 23:10:11 +01:00
IRHM 9dd7ecbff1 1.42.0-dev 2024-07-13 23:05:23 +01:00
oPisiti 96ec6f2c8e Fixed unit oversight (#578) 2024-07-13 21:58:45 +00:00
Mr ae707df709 job: support adding unique jobs (by name) (#583) 2024-07-13 21:57:16 +00:00
Mr 4bbc7ad869 Trakt import (#582)
* wip

* wip

* wip

* wip (also update go version)

* wip

* wip

* wip looks like its working

* increase job done time before deletion

30m seems more reasonable?
2024-07-13 21:27:59 +00:00
Mr 953dcf0034 doc: Create backup guide (#573)
* doc: Create backup guide

* doc: backup: correct grammar i think
2024-07-02 21:08:15 +00:00
IRHM fb4bf1e372 v1.41.0 2024-06-28 04:31:25 +01:00
IRHM ff067d28e6 1.41.0-dev5 2024-06-28 04:10:10 +01:00
IRHM 950e9afffb search: fix loader being hidden after a running request is cancelled
When a search request is running, and the query is changed, the previous request cancellation would hide the loader just after the new request started loading, resulting in the loader being hidden for the duration of the request being completed. Now the loader will only hide if the request errors, not for cancellations too which was causing this issue
2024-06-28 04:09:38 +01:00
IRHM ee15f64e55 1.41.0-dev4 2024-06-28 03:36:42 +01:00
IRHM 7888e0490f #566 Add max=10 validation to Rating in import/watched eps and seasons 2024-06-28 03:35:18 +01:00
IRHM bf45f1a279 1.41.0-dev3 2024-06-28 02:50:20 +01:00
IRHM 0299f03aa1 Ryot import: fix ratings
Ratings are out of 100 on ryot, scaled them down to fit with our out of 10 rating system
2024-06-28 02:48:23 +01:00
IRHM 56d1ed9f10 1.41.0-dev2 2024-06-28 02:29:52 +01:00
oPisiti 855b8722de Ryot import implementation (#563)
* Initial implementation of ryot import process

* Bug fixes for movie imports and show ratings

* Cleanup

* Added Ryot's logo to import button

* Ryot import now supports the DROPPED flag for shows

* Formatted the code and added restrictions for tv shows and movies only

* Better units

* Prettier format

* Fix type errors, change stat styling to push value down

---------

Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-06-28 01:23:04 +00:00
IRHM aff99b5fa5 README: Add third party tools section with kodi plugin 2024-06-27 20:53:03 +01:00
IRHM d4683fb89d search: update page destroyed log 2024-06-26 03:30:43 +01:00
IRHM b61855d00f search: stop afterNavigate running when we only want onMount to 2024-06-26 03:29:46 +01:00
IRHM 3ee763fe29 Search: Support cancelling running searches
with only a few bugs introduced!
2024-06-26 03:10:35 +01:00
IRHM 03f7c51655 Search: Fix big jump when clicking try again and we fail again
Added same margin-bottom to the spinner.
2024-06-26 02:00:46 +01:00
IRHM 0c95468f93 Search: Comment out curPage 2024-06-25 02:18:20 +01:00
IRHM f7b96fb4b9 1.41.0-dev1 2024-06-25 02:16:13 +01:00
IRHM 471f3e3f6f Search: Fix race cond potentially causing all pages to load in background 2024-06-25 02:15:26 +01:00
IRHM 091bce7383 Search: Add comment to curPage increment in multi results 2024-06-25 01:58:33 +01:00
IRHM e3cec34c8b Search: Fix 'try again' not working if initial multi results failed to load 2024-06-25 01:57:18 +01:00
IRHM 4b82913308 1.41.0-dev 2024-06-25 00:49:31 +01:00
IRHM 0a50ead42a Search: Don't show games filter if games are not enabled on server 2024-06-25 00:43:15 +01:00
dependabot[bot] 1428b33279 server: bump github.com/go-co-op/gocron/v2 in /server (#557)
Bumps [github.com/go-co-op/gocron/v2](https://github.com/go-co-op/gocron) from 2.5.0 to 2.7.0.
- [Release notes](https://github.com/go-co-op/gocron/releases)
- [Commits](https://github.com/go-co-op/gocron/compare/v2.5.0...v2.7.0)

---
updated-dependencies:
- dependency-name: github.com/go-co-op/gocron/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:27:40 +00:00
dependabot[bot] 91bf6a6adf ui: bump eslint-plugin-svelte from 2.39.0 to 2.41.0 (#558)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.39.0 to 2.41.0.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/v2.39.0...eslint-plugin-svelte@2.41.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:26:42 +00:00
dependabot[bot] cbafb8c77b ui: bump svelte-eslint-parser from 0.36.0 to 0.39.2 (#559)
Bumps [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser) from 0.36.0 to 0.39.2.
- [Release notes](https://github.com/sveltejs/svelte-eslint-parser/releases)
- [Changelog](https://github.com/sveltejs/svelte-eslint-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-eslint-parser/compare/v0.36.0...v0.39.2)

---
updated-dependencies:
- dependency-name: svelte-eslint-parser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:26:26 +00:00
dependabot[bot] ec12281e9b ui: bump @typescript-eslint/eslint-plugin from 7.12.0 to 7.14.1 (#560)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.12.0 to 7.14.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.14.1/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:25:37 +00:00
dependabot[bot] e65afd8b9d workflow: bump docker/build-push-action from 5 to 6 (#545)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:20:12 +00:00
dependabot[bot] 49d1411e47 server: bump gorm.io/driver/sqlite from 1.5.5 to 1.5.6 in /server (#546)
Bumps [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) from 1.5.5 to 1.5.6.
- [Commits](https://github.com/go-gorm/sqlite/compare/v1.5.5...v1.5.6)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:18:15 +00:00
dependabot[bot] 7ba0b8106d ui: bump prettier from 3.2.5 to 3.3.2 (#549)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.5 to 3.3.2.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.5...3.3.2)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:16:03 +00:00
dependabot[bot] b7cfb904a2 server: bump golang.org/x/crypto from 0.23.0 to 0.24.0 in /server (#539)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/crypto/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-24 23:15:17 +00:00
Mr eed3a7cee7 Merge pull request #562 from sbondCo/search-pages
Search: Infinite scrolling and serverside content type filters
2024-06-24 23:11:25 +00:00
IRHM 014984cdd1 Search: Comment out page num and log instead
Kind of wanted to display the page number somewhere but I can't think of where it could go
2024-06-24 23:53:04 +01:00
IRHM c1f0a954bf Search: Return nothing if higher page than 1 is requested for games
Doesn't support pagination, fixes showing duplicate games on multi search when scrolling down.
2024-06-24 02:25:54 +01:00
IRHM 5ca7841e61 Search: Auto load next page if first doesn't fill page 2024-06-24 02:24:50 +01:00
IRHM 54f8dd8912 Search: Better error handling, add retry button 2024-06-24 02:00:04 +01:00
IRHM fc4cbf3c66 Search: Basic infinite scrolling 2024-06-23 18:24:15 +01:00
IRHM b4cc8f85b7 search: Use new search routes for filters
Instead of using a local filter of `multi`+`game` results.
2024-06-23 18:09:42 +01:00
IRHM 4056178be4 content: Create movie, tv and person search services/routes 2024-06-22 01:03:03 +01:00
IRHM 2aab7b3fce routes: content: Move /:query route to /search/multi/:query 2024-06-22 01:02:12 +01:00
IRHM 9df978a4fd searchContent: Support pagination 2024-06-11 23:50:20 +01:00
IRHM d2a8e54ce0 v1.40.0 2024-06-09 18:28:04 +01:00
IRHM a1d8a894b3 1.40.0-dev2 2024-06-09 17:30:45 +01:00
IRHM dc984fae1f watched_hooks: Update season to watching when held or dropped too 2024-06-09 17:25:08 +01:00
IRHM 1a3c9e0880 watched: WatchedStatus: Fix HOLD value
Has always been set as HOLD from frontend apparently, never used this value before on server so must've missed it (until now, i wasted a lot of time ;().
2024-06-09 17:24:49 +01:00
IRHM 95debb0551 watched_hooks: Set season to watching in more cases, fix reason string 2024-06-09 16:36:50 +01:00
IRHM f3e8377f34 watched_hooks: remove first check, dropped episode statuses will be processed
Fixes the problem where if the user drops the last episode in a season, it wouldn't complete the season, since we wouldn't process it.
2024-06-09 16:19:53 +01:00
IRHM 00bd652752 1.40.0-dev1 2024-06-09 05:21:38 +01:00
IRHM 214abd4110 watched_hooks: comment note of boog 2024-06-09 05:20:41 +01:00
IRHM 22bdadc0db Activity: Fix header font-family 2024-06-09 05:15:00 +01:00
IRHM 04a17af9ca watched_hooks: Fix finishing season when already finished, season status response 2024-06-09 05:11:34 +01:00
IRHM b90dc289f6 watched_hooks: Fix response watchedSeason status when watchedSeason was just created 2024-06-09 05:01:43 +01:00
IRHM 34ab553cd6 1.40.0-dev 2024-06-09 02:25:46 +01:00
dependabot[bot] 85c2c430ce ui: bump eslint-plugin-svelte from 2.35.1 to 2.39.0 (#529)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.35.1 to 2.39.0.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Changelog](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/v2.35.1...v2.39.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-09 01:15:12 +00:00
dependabot[bot] c8c24d9958 ui: bump @sveltejs/kit from 2.5.3 to 2.5.10 (#530)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.3 to 2.5.10.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.5.10/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-09 01:13:59 +00:00
dependabot[bot] 87035f1ee8 ui: bump @typescript-eslint/eslint-plugin from 7.8.0 to 7.12.0 (#534)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.8.0 to 7.12.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.12.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-09 01:11:48 +00:00
dependabot[bot] d5b37ac423 ui: bump axios from 1.6.8 to 1.7.2 (#533)
Bumps [axios](https://github.com/axios/axios) from 1.6.8 to 1.7.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.8...v1.7.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-09 01:09:39 +00:00
Mr 02867d26db plex: Comment out ViewMode property from structs (#538) 2024-06-09 01:07:10 +00:00
Mr eb146517fb Merge pull request #537 from sbondCo/update-related-stuff-setting
AutomateShowStatuses setting
2024-06-09 01:00:25 +00:00
IRHM 5dd4e8f333 AutomateShowStatuses setting
Disables hookEpisodeStatusChanged and will disable future related hooks when they are made
2024-06-09 01:49:49 +01:00
Mr 697a1ecb89 Merge pull request #536 from sbondCo/update-related-stuff
Episode automations
2024-06-08 19:59:37 +00:00
IRHM 15b7fd564b watched_hooks: Make EpisodeStatusChangedHookResponse->WatchedSeason a pointer
Also removed TODO comments and added check to ensure hook response has any keys SeasonsListEpisode
2024-06-08 20:44:28 +01:00
IRHM 0bee8b4fde watched_hooks: Fix watchedSeason response when FINISHED status is set
We weren't ensuring that the whole watched season object was set to hookResponse.WatchedSeason first, so they client wouldn't get the WatchedSeason `id` (so couldn't update the season).
2024-06-08 20:28:28 +01:00
IRHM 64223e9fa5 Mostly finalize episode automations 2024-06-08 20:20:50 +01:00
IRHM faf6df5de5 wip: Basic episode hook to update season/show status
New hookEpisodeStatusChanged method (and others to assist) that performs various checks to see if we should also update the season/shows status.
2024-06-07 15:05:10 +01:00
IRHM 45496fa61a Content: seasonDetails: Manually cache function
Handle caching manually in the function, so it can be used in other places.
2024-06-07 15:03:16 +01:00
dependabot[bot] f5a0495fa9 ui: bump svelte from 4.2.15 to 4.2.17 (#527)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.15 to 4.2.17.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.17/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.17/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-03 03:18:51 +00:00
dependabot[bot] 2461ba65c9 server: bump github.com/gin-gonic/gin from 1.9.1 to 1.10.0 in /server (#521)
Bumps [github.com/gin-gonic/gin](https://github.com/gin-gonic/gin) from 1.9.1 to 1.10.0.
- [Release notes](https://github.com/gin-gonic/gin/releases)
- [Changelog](https://github.com/gin-gonic/gin/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gin-gonic/gin/compare/v1.9.1...v1.10.0)

---
updated-dependencies:
- dependency-name: github.com/gin-gonic/gin
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 16:48:19 +00:00
dependabot[bot] a61de6a831 server: bump github.com/gin-contrib/cache from 1.2.0 to 1.3.0 in /server (#522)
Bumps [github.com/gin-contrib/cache](https://github.com/gin-contrib/cache) from 1.2.0 to 1.3.0.
- [Release notes](https://github.com/gin-contrib/cache/releases)
- [Changelog](https://github.com/gin-contrib/cache/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cache/compare/v1.2.0...v1.3.0)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cache
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 16:45:12 +00:00
dependabot[bot] d7e1a8d5a5 ui: bump svelte-eslint-parser from 0.33.1 to 0.36.0 (#520)
Bumps [svelte-eslint-parser](https://github.com/sveltejs/svelte-eslint-parser) from 0.33.1 to 0.36.0.
- [Release notes](https://github.com/sveltejs/svelte-eslint-parser/releases)
- [Changelog](https://github.com/sveltejs/svelte-eslint-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-eslint-parser/compare/v0.33.1...v0.36.0)

---
updated-dependencies:
- dependency-name: svelte-eslint-parser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 16:42:10 +00:00
dependabot[bot] 27fcba929e ui: bump @vite-pwa/sveltekit from 0.3.0 to 0.5.0 (#512)
Bumps [@vite-pwa/sveltekit](https://github.com/vite-pwa/sveltekit) from 0.3.0 to 0.5.0.
- [Release notes](https://github.com/vite-pwa/sveltekit/releases)
- [Commits](https://github.com/vite-pwa/sveltekit/compare/v0.3.0...v0.5.0)

---
updated-dependencies:
- dependency-name: "@vite-pwa/sveltekit"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 16:41:23 +00:00
dependabot[bot] dff50559bc ui: bump svelte-preprocess from 5.1.3 to 5.1.4 (#519)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.3 to 5.1.4.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.3...v5.1.4)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 16:38:11 +00:00
Mr 62ca49816d Merge pull request #526 from sbondCo/better-tasks
Better tasks
2024-05-21 16:34:21 +00:00
IRHM 127b2481e0 TaskScheduleModal: Remove unused css 2024-05-21 17:32:37 +01:00
IRHM 6c8b2235f1 TaskScheduleModal: Sort tasks 2024-05-21 17:14:12 +01:00
dependabot[bot] ef12ad5f2c --- (#525)
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-21 13:08:03 +00:00
IRHM ab1da011e6 TaskScheduleModal: Reschedule loading notif, increase getTasksInterval to 8s 2024-05-20 23:56:24 +01:00
IRHM eb643ba5f7 tasks: Save schedule to config when rescheduling 2024-05-20 23:55:52 +01:00
IRHM ebc954e6d4 TaskScheduleModal: Basic rescheduling 2024-05-20 23:38:53 +01:00
IRHM a17aac9303 TaskScheduleModal: Display next run times 2024-05-20 23:17:28 +01:00
dependabot[bot] 21a35a2daa ui: bump vite from 5.2.8 to 5.2.11 (#518)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.8 to 5.2.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-19 21:12:05 +00:00
IRHM 0b8fb35809 Settings: Simple TaskScheduleModal
Currently only displays the schedules
2024-05-19 22:11:14 +01:00
IRHM aa2555cb36 tasks: Rework taskFuncs so we can return seconds in getAllTasks 2024-05-19 22:10:17 +01:00
IRHM 127393245d tasks: Create task_schedule config
Use it, just need a frontend and way to edit it.
2024-05-19 21:20:23 +01:00
IRHM 7868015ce6 tasks: Replace tasks scheduling with go-cron
The server can now handle rescheduling the tasks (just need to save schedules in config).
2024-05-19 21:03:43 +01:00
dependabot[bot] 7e40fe0eda ui: bump @typescript-eslint/parser from 7.6.0 to 7.8.0 (#506)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.6.0 to 7.8.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.8.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:37:44 +00:00
dependabot[bot] 16688b6932 ui: bump prettier-plugin-svelte from 3.2.2 to 3.2.3 (#483)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.2.2 to 3.2.3.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.2.2...v3.2.3)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:34:04 +00:00
dependabot[bot] 3513f0da4b ui: bump @typescript-eslint/eslint-plugin from 7.4.0 to 7.8.0 (#507)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.4.0 to 7.8.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.8.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:33:30 +00:00
dependabot[bot] c85fd32f0b ui: bump svelte from 4.2.12 to 4.2.15 (#501)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.12 to 4.2.15.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.15/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.15/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:30:26 +00:00
dependabot[bot] c8523799be server: bump gorm.io/gorm from 1.25.9 to 1.25.10 in /server (#505)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.9 to 1.25.10.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.9...v1.25.10)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:28:39 +00:00
dependabot[bot] 050798f7a1 server: bump github.com/gin-contrib/cors from 1.7.1 to 1.7.2 in /server (#513)
Bumps [github.com/gin-contrib/cors](https://github.com/gin-contrib/cors) from 1.7.1 to 1.7.2.
- [Release notes](https://github.com/gin-contrib/cors/releases)
- [Changelog](https://github.com/gin-contrib/cors/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cors/compare/v1.7.1...v1.7.2)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cors
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:26:55 +00:00
dependabot[bot] bf4d479a07 server: bump golang.org/x/crypto from 0.22.0 to 0.23.0 in /server (#514)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.22.0 to 0.23.0.
- [Commits](https://github.com/golang/crypto/compare/v0.22.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-07 22:23:24 +00:00
IRHM fb5a5c309b v1.39.0 2024-04-23 02:25:46 +01:00
IRHM 9dc5684a3e 1.39.0-dev2 2024-04-23 01:51:51 +01:00
Mr 7083e2c90e Support emby branding (#504)
* Support emby branding

Swap out jellyfin branding for emby when USE_EMBY is enabled.

* Server: Change jellyfin host setting text to emby when USE_EMBY enabled
2024-04-23 00:49:15 +00:00
IRHM a2266afcdc 1.39.0-dev1 2024-04-22 04:53:37 +01:00
IRHM 16a4316854 Search: Fix filtering for a type, then searching and no results for that type showing, hiding the button
basically buddy, if you search for something and filter for 'movies' (for example), then you change the search to something else, but no movies come up in the results, the 'movies' filter would hide, but still be active. That was confusing behavour, so now the button 'movies' in this case will always stay visible, if it's the active filter.
2024-04-22 04:49:12 +01:00
IRHM 727c3fecbb MyThoughts: Fix border color on dark theme 2024-04-22 04:37:10 +01:00
IRHM 03f2a13dba 1.39.0-dev 2024-04-22 04:17:59 +01:00
Mr 068ff846e6 Content Pages: Make btns container wrap elements when overflowing (#499) 2024-04-22 02:48:49 +00:00
Mr 10923fdf05 Support pinning watched list items to top (#498)
* Support for pinning watched list items

* GamePoster: Support `pinned` prop
2024-04-22 02:21:21 +00:00
Mr b1a2b3df8a Support detailed view on search page (#497)
* Support detailed view on search page

* WatchedList: Remove unused imports
2024-04-21 19:51:05 +00:00
Mr 7a8c433466 Improve search (filters, remove dumb sort) (#496)
* Search: Add filters (for content type), remove terrible result sort, dynamic page title

* Search: Better responsiveness for filters

Moving margin to wrapper and adding to right makes it wrap sooner.

* Search: Even better responsiveness for filters

On smaller screens, changes flex-flow to column and centers buttons
2024-04-21 17:11:33 +00:00
Mr 13410023e2 Merge pull request #495 from sbondCo/better-thoughts
Better thoughts
2024-04-20 20:32:14 +00:00
IRHM 265fc4bafe MyThoughts: Change modal description (remove 'review/')
Removed the word review, I think it's fairly obvious and it makes the sentence longer for now reason.

I think it's nicer without it.
2024-04-20 21:31:34 +01:00
IRHM 46f5aea674 Content: Made rating section max-width smaller on viewports 420px and below 2024-04-20 21:25:46 +01:00
IRHM 45e65c234a MyThoughts: Don't close if saving failed
to avoid thought changes being lost if the server fails when saving.

Also stopped saving changed on blur of textarea, now only saves when close modal is clicked.
2024-04-20 21:01:21 +01:00
IRHM eba923c929 api: Return bool (was success) from update watched/played funcs 2024-04-20 20:59:56 +01:00
IRHM b7fe07a1ef Create and use MyThoughts component
Now displays preview of thoughts as a button, which when clicked will open a modal with the full review.
2024-04-20 20:32:37 +01:00
Mr 19c4bc415e LastFinished Sort: Use customDate, use updatedAt as fallback (#494) 2024-04-20 17:00:45 +00:00
Mr 3684c79477 v1.38.2 2024-04-18 14:36:41 +01:00
Mr b749bffa15 Remove version properties from docker-compose examples 2024-04-18 14:28:52 +01:00
Mr b800369165 container-release: Fix metadata step (probably) 2024-04-18 14:13:10 +01:00
IRHM 5ac07c0f6e v1.38.1 2024-04-18 02:41:42 +01:00
IRHM 434d3fca3d WatchedList: Catch and show if filtering/sorting list fails 2024-04-18 02:39:19 +01:00
IRHM 57c7fb1dec Watched: Preload Activity for public watched lists
To support 'Last Finished' filter on public lists.
Also added user_id to logs in getPublicWatched.
2024-04-18 02:36:18 +01:00
IRHM f209709200 WatchedList: Fix 'Last Finished' filter on public lists
Where activity is not currently provided.
2024-04-18 02:35:06 +01:00
IRHM f5b15854e0 v1.38.0 2024-04-18 02:00:14 +01:00
IRHM 6aec9a938a 1.38.0-dev 2024-04-18 01:07:32 +01:00
IRHM 8180780c87 arr_requests: Link to content on title 2024-04-18 00:44:33 +01:00
Mr a8b369fdb5 Games: Fix not able to add games previously removed from list (#487) 2024-04-15 22:38:15 +00:00
Mr 0c6a550871 Add delete button to content pages (#486) 2024-04-15 22:34:47 +00:00
Mr b006fafbdf import: Fix customDates all coming back the same in all activity (#485) 2024-04-15 21:57:50 +00:00
Mr 3c0c014b6a Sort by last finished/played (#479)
* Nav: Swap filter and sort icons (and positions)

* Last finished sort

I feel disgusting every time i touch this sort/filter code
2024-04-15 02:09:47 +00:00
dependabot[bot] c086348a44 server: bump golang.org/x/crypto from 0.21.0 to 0.22.0 in /server (#470)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.21.0 to 0.22.0.
- [Commits](https://github.com/golang/crypto/compare/v0.21.0...v0.22.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 17:13:28 +00:00
dependabot[bot] 6bc18e61ed workflow: bump peaceiris/actions-gh-pages from 3 to 4 (#465)
Bumps [peaceiris/actions-gh-pages](https://github.com/peaceiris/actions-gh-pages) from 3 to 4.
- [Release notes](https://github.com/peaceiris/actions-gh-pages/releases)
- [Changelog](https://github.com/peaceiris/actions-gh-pages/blob/main/CHANGELOG.md)
- [Commits](https://github.com/peaceiris/actions-gh-pages/compare/v3...v4)

---
updated-dependencies:
- dependency-name: peaceiris/actions-gh-pages
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 17:13:02 +00:00
Mr fc714ebfe0 ProvidersList: Also display free providers (#478)
- Display free providers
- Align text/icons to center
- Add BCC iPlayer and Hulu provider icons
2024-04-14 17:08:24 +00:00
dependabot[bot] 5018805ffe ui: bump @typescript-eslint/parser from 7.2.0 to 7.6.0 (#466)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.2.0 to 7.6.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.6.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 03:08:38 +00:00
dependabot[bot] c6c48950c4 ui: bump svelte-check from 3.6.4 to 3.6.9 (#467)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.6.4 to 3.6.9.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.6.4...svelte-check-3.6.9)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 03:08:14 +00:00
dependabot[bot] 75baa5468c ui: bump typescript from 5.3.3 to 5.4.5 (#475)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.3 to 5.4.5.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.3...v5.4.5)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 03:07:25 +00:00
dependabot[bot] c462aca6ed ui: bump vite from 5.1.6 to 5.2.8 (#469)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.6 to 5.2.8.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.8/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-14 03:00:33 +00:00
stignarnia c5d817c68c Ability to set a country, both in server and user settings, to have the correct streaming provider (but doesn't affect language) (#463)
* Country support for watch providers (#1, not functional)

* frontend dropdowns now actually read and write the correct setting

* user country setting now respects the server side default when creating a new user

* watch providers correctly show based on country, but changing the coutnry requires restarting the container to take effect

* flushing cache when updating user settings

* non functional, initial support for getting all the countries of which tmdb has streaming providers for to let users pick one

* users get shown a list of country codes to pick from and the selection gets saved and used, but the dropdown menu doesn't allow for scrolling

* limited dropdown height and auto scroll to the letter the user hits

* simplified auto scroll code

* the dropdown to choose countries now shows their full english name

* server/user region improvements

* DropDown: remove console log

used to test

* DropDown: Add keypress event listener on main div

instead of document body, so only triggered when key pressed whilst focused on dropdown.

---------

Co-authored-by: Stig <stig.narnia@gmail.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-04-14 02:57:51 +00:00
Mr 4a7503bbfd Merge pull request #474 from sbondCo/arr-progress
Arr progress
2024-04-12 23:46:32 +00:00
IRHM c08686a255 idkanymore 2024-04-13 00:32:03 +01:00
IRHM 6cad057e58 Fix: getSonarrQueueDetails
Was calling to radarr.
2024-04-10 23:06:52 +01:00
IRHM d62e911cc2 wip: arr progress - response requests management, auto approve perm ui 2024-04-08 00:18:37 +01:00
IRHM 82b0941cd4 wip: arr progress - requests: display created at time, sort by update time 2024-04-07 21:02:51 +01:00
IRHM 3a5ed7cdc0 wip: arr progress - set status from request in request button 2024-04-07 18:02:48 +01:00
IRHM ae32bb4955 wip: arr progress - info calls, stop deleting request on 404 if request hasnt been approved yet 2024-04-07 18:02:29 +01:00
IRHM 42ca418d55 wip: arr progress - request approval/denial 2024-04-07 17:42:58 +01:00
IRHM a4fab86472 wip: arr progress - manage arr requests ui
the basic request management ui and the get all requests route
2024-04-03 06:28:18 +01:00
IRHM 0c5c9eba6e wip: arr progress - tv requests 2024-04-02 06:10:02 +01:00
IRHM a58d940473 wip: arr progress - handle more cases a movie could be in 2024-04-01 22:55:39 +01:00
IRHM c415bf4c52 wip: arr progress - Radarr progress 2024-04-01 01:42:53 +01:00
IRHM cb59a9e6b1 workflow: container-release: Update to use docker hub (copied from container-dev changes) 2024-03-30 23:12:04 +00:00
IRHM bedcfcc07c workflow: container-dev: fix tags/labels
at least i hope this is the fix
2024-03-30 22:37:40 +00:00
IRHM a95365525a workflow: container-dev: Also push to docker hub
testing out pushing to docker hub
2024-03-30 22:16:34 +00:00
dependabot[bot] f54c8feb2f ui: bump @typescript-eslint/eslint-plugin from 7.2.0 to 7.4.0 (#434)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.2.0 to 7.4.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.4.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:06:46 +00:00
dependabot[bot] 4dee46a7f7 ui: bump @sveltejs/adapter-node from 4.0.1 to 5.0.1 (#419)
Bumps [@sveltejs/adapter-node](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-node) from 4.0.1 to 5.0.1.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-node/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-node@5.0.1/packages/adapter-node)

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-node"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:06:23 +00:00
dependabot[bot] a7e564af1e ui: bump prettier-plugin-svelte from 3.2.1 to 3.2.2 (#417)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.2.1 to 3.2.2.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.2.1...v3.2.2)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:05:55 +00:00
dependabot[bot] f72fa54931 ui: bump axios from 1.6.7 to 1.6.8 (#418)
Bumps [axios](https://github.com/axios/axios) from 1.6.7 to 1.6.8.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.7...v1.6.8)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:02:59 +00:00
dependabot[bot] 0089ea416e ui: bump svelte from 4.2.10 to 4.2.12 (#421)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.10 to 4.2.12.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.12/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.12/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:02:32 +00:00
dependabot[bot] a163e4c9be server: bump github.com/gin-contrib/cors from 1.7.0 to 1.7.1 in /server (#435)
Bumps [github.com/gin-contrib/cors](https://github.com/gin-contrib/cors) from 1.7.0 to 1.7.1.
- [Release notes](https://github.com/gin-contrib/cors/releases)
- [Changelog](https://github.com/gin-contrib/cors/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cors/compare/v1.7.0...v1.7.1)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cors
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:01:48 +00:00
dependabot[bot] b5028f5405 server: bump gorm.io/gorm from 1.25.7 to 1.25.9 in /server (#449)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.7 to 1.25.9.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.7...v1.25.9)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-29 23:01:03 +00:00
Mr 70ed52cec4 Merge pull request #445 from sbondCo/doc-envvars
docs: Environment Variables
2024-03-27 22:56:16 +00:00
IRHM 63700a5a1e docs: Environment Variables 2024-03-27 22:55:09 +00:00
IRHM 60323ecd61 v1.37.0 2024-03-27 22:23:59 +00:00
IRHM 49b3c8e96a 1.37.0-dev 2024-03-27 21:43:53 +00:00
IRHM 2d39e29db0 PosterList: Responsive fix AGAIN ;( 2024-03-27 21:02:17 +00:00
IRHM 8c39ec881e manage_users: Change username cell flex-flow to col for mobile 2024-03-27 20:11:02 +00:00
IRHM 3ea7001986 import: Make pages use 100% width on smaller screens 2024-03-27 20:05:11 +00:00
Mr ec4b9c993f Import: support importing watched episodes/seasons from watcharr export 2024-03-27 00:50:08 +00:00
Mr 248935c4e7 manage_users: Add word-wrap: anywhere to table cells
So long usernames don't break the tables usage
2024-03-26 23:54:21 +00:00
Mr cfbc8def98 Merge pull request #440 from sbondCo/imgbot
[ImgBot] Optimize images
2024-03-26 12:30:59 +00:00
ImgBotApp 6453f7c274 [ImgBot] Optimize images
*Total -- 96.10kb -> 77.57kb (19.28%)

/doc/docs/installation/img/forking-repo.png -- 1.84kb -> 1.32kb (28.18%)
/doc/docs/server_config/img/watcharr-jellyfin-host-setting.png -- 12.35kb -> 9.61kb (22.16%)
/doc/docs/server_config/img/request-admin/admin-token-generated-log-example.png -- 19.12kb -> 15.14kb (20.8%)
/doc/docs/server_config/img/watcharr-plex-host-setting-filled.png -- 16.79kb -> 13.46kb (19.85%)
/doc/docs/importing/img/mal/export.png -- 10.68kb -> 8.64kb (19.09%)
/doc/docs/server_config/img/watcharr-plex-host-setting.png -- 11.36kb -> 9.20kb (19.04%)
/doc/docs/server_config/img/request-admin/request-admin-btn.png -- 6.97kb -> 5.76kb (17.36%)
/doc/docs/server_config/img/request-admin/redeeming-admin-token.png -- 17.00kb -> 14.44kb (15.02%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-03-26 05:25:08 +00:00
Mr 7261f2f454 Merge pull request #439 from sbondCo/import-myanimelist
Importing from MyAnimeList
2024-03-26 02:33:46 +00:00
IRHM 1e6e22942c myanimelist importing: import start and finish dates 2024-03-26 02:28:35 +00:00
IRHM 3b556ef5e7 Enable fluidSize on posters on search and people pages 2024-03-26 01:20:24 +00:00
IRHM dc05c7aaeb Basic MyAnimeList importing
also fixed poster sizing - added fluidSize property so we only have that on the watched list
2024-03-26 01:18:33 +00:00
stephaje ef0d119907 Import Watcharr Exports (#389)
* Issue #130

* Watcharr import: support activity import and fix ImportedList data

Also added padding to bottom of import page

---------

Co-authored-by: John Stephani <me@johnstephani.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-03-25 22:12:57 +00:00
Mr a12ac3c4a0 Merge pull request #438 from sbondCo/doc-restoring-admin
docs: restoring admin guide
2024-03-25 21:19:03 +00:00
Mr 564788a4c8 Merge pull request #437 from sbondCo/user-management
User management
2024-03-25 21:16:41 +00:00
IRHM 06ddda1137 EditUserModal: Remove unused style 2024-03-25 21:12:38 +00:00
IRHM 48a969b367 Link 'Users' stat to manage_users page 2024-03-25 21:06:08 +00:00
IRHM 5c08980e27 Support PERM_REQUEST_CONTENT on sonarr/radarr routes
Now content can be requested with only the PERM_REQUEST_CONTENT permission required.
2024-03-25 21:03:16 +00:00
Mr 96575c2c6d docs: restoring admin guide
Taken from v1.19.0 release (from before we had a doc site)
2024-03-25 16:32:02 +00:00
IRHM ea3a5d99f0 manage_users: Get all users again after edit user modal closes
fetches data again, because I'm too lazy to make it work locally. Also don't think it's that crucial just for an admin page to make it super duper deliciouso at the el momento
2024-03-25 02:16:14 +00:00
IRHM c47b9a07b8 manage_users: admin tag on table 2024-03-25 02:08:02 +00:00
IRHM c8743fadb6 manage_users: manage user perms 2024-03-25 01:56:00 +00:00
IRHM 42f2cde43f manage_users: Basic user management table
tldr: viewing all users in a table now works
2024-03-24 18:42:39 +00:00
Mr ac1ac76891 Merge pull request #432 from sbondCo/inst-doc
docs: installation for development
2024-03-24 18:08:05 +00:00
IRHM 7abed85a56 docs: installation for development
rough guide
2024-03-24 18:02:48 +00:00
Mr f20e52bbe6 Merge pull request #428 from sbondCo/mobile-responsiveness-fixes
Posters and Search mobile responsiveness fixes
2024-03-24 00:03:05 +00:00
IRHM bea8294f26 Move poster media queries to PosterList
So we can only apply then when the PosterList is in wrapping mode.

Added default min-width to posters of 170px.

This fixes posters on other pages, where they would be super small.
2024-03-23 23:59:59 +00:00
IRHM ca11301410 Nav: Searchbar even more centered better 2024-03-23 23:11:57 +00:00
IRHM b1f48309aa Nav: Make searchbox look a little more centered 2024-03-23 23:08:19 +00:00
IRHM d989f13621 Nav: Fix searchbar responsiveness on other pages
and add text-overflow
2024-03-23 23:04:08 +00:00
IRHM 85cd946619 Nav: Slowly decrease gap instead of always splitting nav below 390px screen size 2024-03-23 22:48:02 +00:00
IRHM 40420aa14b Nav: Keep main input on lower screens by replacing placeholder with a search icon 2024-03-23 22:41:24 +00:00
IRHM aec1014acf Nav: More responsive search bar
We now have two search bars in the markup to make this easily possible. When the size of the main input becomes too small to see the placeholder, we hide it and show another search bar below the main nav content.
2024-03-23 21:31:18 +00:00
IRHM fc49e9986f Poster: More responsive for smaller screens
Small range where we have a dynamic size to hopefully work better on a bigger range of devices. Can't go too big or too small otherwise the posters can become ugly and unusable.

Works well now with all the preset device sizes in my browser.
2024-03-20 22:43:22 +00:00
mordquist c9e23e1eba Configurable data location (#423)
* envvar to configure datadir if set

* config: Rename data environment variable

---------

Co-authored-by: mordquist <mordquist@proton.me>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2024-03-20 21:22:06 +00:00
IRHM f992841314 docs: jellyfin and plex auth docs, add description to all install/config docs 2024-03-18 02:56:51 +00:00
IRHM afb1fa6dfd v1.36.0 2024-03-18 02:30:39 +00:00
IRHM a58351c539 1.36.0-dev1 2024-03-18 01:41:51 +00:00
IRHM d643d349ac Plex Auth: Remove plexUserHasAccessToPlexHost and use getPlexHomeServerAuthToken instead
If we can get an auth token for our home server, then the user must have access to it.

plexUserHasAccessToPlexHost method wouldn't work if our plex server had zero allowed users (except ourselves), since the data would not appear for ourself.
2024-03-18 01:40:45 +00:00
IRHM 4fdb3e9f9d 1.36.0-dev 2024-03-17 18:48:57 +00:00
Mr c1be94738b Jobs: Use wildcard for get job route id param (#415)
Fixes 404 errors when there is a '/' in the job id.
2024-03-17 18:22:27 +00:00
IRHM ea66615f65 plex: Remove info log 2024-03-17 17:39:00 +00:00
IRHM 5b38f762ed Remove old plex remnants
- $plex scss variable
- plexOauthId property in AvailableAuthProviders interface
2024-03-17 17:30:36 +00:00
dependabot[bot] 2aa81f036e Bump follow-redirects from 1.15.5 to 1.15.6 in /doc (#414)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.5 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.5...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-17 17:18:07 +00:00
Mr cbf0120647 Merge pull request #412 from sbondCo/dependabot/npm_and_yarn/follow-redirects-1.15.6
ui: bump follow-redirects from 1.15.4 to 1.15.6
2024-03-17 17:17:49 +00:00
Mr 2865115b1f Merge pull request #413 from sbondCo/plex-sync
Plex sync
2024-03-17 17:13:19 +00:00
IRHM b21d2bf603 Plex: Move auth tokens to new UserServices table and get local auth tokens 2024-03-17 17:04:10 +00:00
IRHM 0667f33250 plex sync: dont parse lastViewedAt for seasons/episodes if not set 2024-03-16 23:37:44 +00:00
IRHM 0da264480c plex auth: update auth token on login
hm mite save twice on creation
2024-03-16 23:23:39 +00:00
IRHM 622d10e743 Plex sync seasons and episodes 2024-03-16 23:18:32 +00:00
dependabot[bot] 5cbc817089 ui: bump follow-redirects from 1.15.4 to 1.15.6
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.4 to 1.15.6.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.4...v1.15.6)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-16 22:36:17 +00:00
IRHM 7c216f4691 Plex sync movies and shows
Based on #401 - Seemed easier to port the relevant changes with some modifications than to fix merge conflicts and mess with history.

Co-Authored-By: stephaje <57157278+stephaje@users.noreply.github.com>
2024-03-16 22:26:01 +00:00
dependabot[bot] b5f9700345 ui: bump @typescript-eslint/eslint-plugin from 7.0.2 to 7.2.0 (#407)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 7.0.2 to 7.2.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.2.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-12 19:59:37 +00:00
Mr 7e03557e56 README: Add matrix chat badge to top and text in getting help section 2024-03-11 21:08:23 +00:00
dependabot[bot] 10a79a31cd server: bump github.com/gin-contrib/cors from 1.5.0 to 1.7.0 in /server (#403)
Bumps [github.com/gin-contrib/cors](https://github.com/gin-contrib/cors) from 1.5.0 to 1.7.0.
- [Release notes](https://github.com/gin-contrib/cors/releases)
- [Changelog](https://github.com/gin-contrib/cors/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cors/compare/v1.5.0...v1.7.0)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cors
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:28:42 +00:00
dependabot[bot] e41d408cd0 ui: bump eslint from 8.56.0 to 8.57.0 (#378)
Bumps [eslint](https://github.com/eslint/eslint) from 8.56.0 to 8.57.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.56.0...v8.57.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:27:40 +00:00
dependabot[bot] 6b22b4a4e2 server: bump golang.org/x/crypto from 0.19.0 to 0.21.0 in /server (#393)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.19.0 to 0.21.0.
- [Commits](https://github.com/golang/crypto/compare/v0.19.0...v0.21.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:26:52 +00:00
dependabot[bot] 67c3083edd server: bump github.com/golang-jwt/jwt/v5 from 5.2.0 to 5.2.1 in /server (#404)
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.2.0 to 5.2.1.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.2.0...v5.2.1)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:26:25 +00:00
dependabot[bot] e968b7ff28 ui: bump @typescript-eslint/parser from 7.0.2 to 7.2.0 (#406)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 7.0.2 to 7.2.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.2.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:25:39 +00:00
dependabot[bot] adfd983a68 ui: bump @sveltejs/kit from 2.5.0 to 2.5.3 (#405)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.5.0 to 2.5.3.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.5.3/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:25:08 +00:00
dependabot[bot] b32ba86c5d ui: bump vite from 5.1.3 to 5.1.6 (#408)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.3 to 5.1.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 20:23:57 +00:00
Mr ebb0b132bd plex auth improvements (#402)
- Our own auth flow, removed 'plex-oauth' dep.
- Fixed normal registrations
- Combined login/register plex methods
- Changed login page plex btn styling
- Reverted colors on jellyfin/plex btns (couldn't find a nice one and it hurt me)
- Removed plex client id from server config, replaced by plex host and plex machine id config items
- Plex client id is generated on the client and stored for reuse, each client will do that instead of the server storing it for everyone.
- Use plex machine id to ensure only approved users can login via plex (users with access to host server library)
- Renamed some structs and funcs
- Removed background from plex icon
- Made buttons and inputs bigger (more padding, also unrelated to rest of changed but i think it looks nice)
2024-03-11 03:21:03 +00:00
stephaje 4f1fbf4a45 Plex Auth (#387) 2024-03-10 00:49:04 +00:00
IRHM 74f896a8bc docs: Fix viewjs css not loading in build 2024-03-09 03:42:22 +00:00
IRHM 3b1d42c148 docs: Screenshots (+ viewerjs for viewing them closer), demo link in nav 2024-03-09 01:55:06 +00:00
IRHM 7a902212a5 v1.35.2 2024-03-07 11:24:34 +00:00
IRHM 2a718ef6d8 #400 Fix signup_enabled default config value
Set defaults when generating config so they are stored in the json file.
2024-03-07 11:23:56 +00:00
IRHM f71dc287bd v1.35.1 2024-03-06 23:29:06 +00:00
IRHM 77d2c6f49a 1.35.1-dev 2024-03-06 23:10:29 +00:00
Mr 3eb454cc93 Config: Don't omit SIGNUP_ENABLED when empty (#399)
Was being removed from config when set to false, then once the server restarts would be set back to default value (true).

Now is kept in config with value of false when turned off.
2024-03-06 22:40:50 +00:00
Mr 8a5fd8182f Add restart property to docker-compose example (#398)
* #390

and update docker-compose file in proj root to match one in docs

* docker-compose.yml: fix port

i changed this :(
2024-03-06 22:31:47 +00:00
IRHM aa7a38b2ee v1.35.0 2024-03-05 20:50:09 +00:00
IRHM ed7a780c44 1.35.0-dev 2024-03-05 18:57:19 +00:00
Mr 32f72f382e Merge pull request #395 from sbondCo/jellyfin-sync
Jellyfin sync improvements
2024-03-05 18:20:34 +00:00
IRHM 2e0ea4d6c7 seasonAndEpToReadable: Remove padStart and fix showing season 0 as unknown
season 0 = specials, dont want to show 'unknown' in this case.
2024-03-05 17:54:23 +00:00
IRHM 2a9a3691a3 Jellyfin Sync: Add full_item debug log for episodes and seasons 2024-03-05 17:41:14 +00:00
IRHM 49692f5e02 Jellyfin Sync: Save episode/season watch dates
and change activity to show 'synced' instead of 'imported' for JF types
2024-03-05 17:38:44 +00:00
Mr 5f06e7b52f Merge pull request #394 from sbondCo/jellyfin-sync
Jellyfin (Manual) Sync
2024-03-05 01:04:19 +00:00
IRHM 1933ca350d jobs: Remove job after 1m of status being set to done 2024-03-05 01:03:00 +00:00
IRHM 337957fb9f Jellyfin Sync: _JF activity types
Not doing anything with them now, but later we can add to the UI (maybe jf icon if _JF activity variant)
2024-03-05 00:33:52 +00:00
IRHM 458c91d505 Jellyfin Sync: Don't add imported date activity if movie already on list 2024-03-05 00:00:08 +00:00
IRHM 5498236b48 JellyfinSyncModal: Stop job watcher after 10 errors in a row 2024-03-04 23:49:45 +00:00
IRHM 0e7cdb0199 watched episodes/seasons: Don't add activity if we didn't update the status/rating 2024-03-04 20:35:55 +00:00
IRHM 7d25b0bf5d wip: jellyfin sync modal 2024-03-04 03:50:41 +00:00
IRHM ccadcf2152 wip: jellyfin sync 2024-03-03 22:53:05 +00:00
IRHM 8e0fb190d1 ui: bump @typescript-eslint (eslint-plugin and parser) to 7.0.2 2024-02-22 02:43:03 +00:00
dependabot[bot] 62033c66aa ui: bump svelte-check from 3.6.3 to 3.6.4 (#370)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.6.3 to 3.6.4.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.6.3...svelte-check-3.6.4)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 02:36:12 +00:00
dependabot[bot] 95130fd4fb ui: bump prettier-plugin-svelte from 3.1.2 to 3.2.1 (#368)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.1.2 to 3.2.1.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.1.2...v3.2.1)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 02:35:54 +00:00
dependabot[bot] c4cebb717c ui: bump @typescript-eslint/eslint-plugin from 6.21.0 to 7.0.0 (#367)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.21.0 to 7.0.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.0.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 02:35:21 +00:00
dependabot[bot] d92dd51bc8 ui: bump vite from 5.0.12 to 5.1.3 (#369)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.12 to 5.1.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-22 02:34:55 +00:00
Mr 4e4f8f9fcf ActivityEditor: Accept any valid date (#372)
* Activity: Allow any valid date in editor, add try/catch to updateActivity method

* api: Update updateActivity log
2024-02-22 02:33:34 +00:00
Mr 11a0ec55d5 Merge pull request #366 from stephaje/Issue-#61
Issue #61 - Editing activity
2024-02-22 01:48:46 +00:00
John Stephani 14b3f38b5d Add client validation 2024-02-21 19:17:13 -06:00
stephaje 5799cd649b context => c 2024-02-19 12:44:11 -06:00
stephaje d4b873ec7d Clean up errors, CSS, fix tv/games activity 2024-02-18 23:26:31 -06:00
stephaje d00ae04074 Add user id checking; fix log/error messages 2024-02-18 14:00:07 -06:00
stephaje 8955ca7b26 npm run format 2024-02-18 10:52:17 -06:00
stephaje 82c6729a22 Issue #61 2024-02-18 10:47:16 -06:00
IRHM 1929272705 v1.34.0 2024-02-18 16:45:56 +00:00
IRHM 709dead520 1.34.0-dev 2024-02-18 16:21:40 +00:00
Mr 7cd348804c Merge pull request #365 from sbondCo/fix-discovery
Fix discovery (trending) requests
2024-02-18 16:20:25 +00:00
IRHM ba91f65cee #364 Fix discovery requests
Trailing / causing 404
2024-02-18 16:18:59 +00:00
Mr a09488dbba Merge pull request #363 from sbondCo/rate-episodes
Ratings and Statuses for episodes
2024-02-18 16:08:23 +00:00
IRHM 111b2b1afd getLatestWatchedInTv: Fix undefined check, should be looking for bigger than -1 2024-02-18 16:04:13 +00:00
IRHM c0ff3205a6 Include episode in watching season extra detail 2024-02-18 15:43:59 +00:00
IRHM 92815f6907 #42 Episode Ratings 2024-02-17 23:15:43 +00:00
dependabot[bot] d9257e767f server: bump gorm.io/driver/sqlite from 1.5.4 to 1.5.5 in /server (#353)
Bumps [gorm.io/driver/sqlite](https://github.com/go-gorm/sqlite) from 1.5.4 to 1.5.5.
- [Commits](https://github.com/go-gorm/sqlite/compare/v1.5.4...v1.5.5)

---
updated-dependencies:
- dependency-name: gorm.io/driver/sqlite
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:12:43 +00:00
dependabot[bot] 26ec0f592a server: bump golang.org/x/crypto from 0.18.0 to 0.19.0 in /server (#354)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.18.0 to 0.19.0.
- [Commits](https://github.com/golang/crypto/compare/v0.18.0...v0.19.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:11:09 +00:00
dependabot[bot] 1e4814d50a server: bump gorm.io/gorm from 1.25.6 to 1.25.7 in /server (#355)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.6 to 1.25.7.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.6...v1.25.7)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:10:49 +00:00
dependabot[bot] bbe2123c1e ui: bump prettier from 3.2.4 to 3.2.5 (#341)
Bumps [prettier](https://github.com/prettier/prettier) from 3.2.4 to 3.2.5.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.2.4...3.2.5)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:07:04 +00:00
dependabot[bot] d7c2897698 ui: bump @typescript-eslint/eslint-plugin from 6.20.0 to 6.21.0 (#338)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.20.0 to 6.21.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.21.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:05:45 +00:00
dependabot[bot] 8cd930369d ui: bump svelte from 4.2.8 to 4.2.10 (#337)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.8 to 4.2.10.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.10/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.10/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:04:47 +00:00
dependabot[bot] 4cea72d8e9 ui: bump axios from 1.6.5 to 1.6.7 (#339)
Bumps [axios](https://github.com/axios/axios) from 1.6.5 to 1.6.7.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.5...v1.6.7)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-16 23:04:13 +00:00
imgbot[bot] e9a4629c2a [ImgBot] Optimize images (#357)
*Total -- 936.02kb -> 774.21kb (17.29%)

/doc/docs/server_config/img/twitch-register-app.png -- 80.15kb -> 58.27kb (27.29%)
/doc/docs/server_config/img/watcharr-settings-dropdown.png -- 8.20kb -> 6.11kb (25.44%)
/doc/docs/server_config/img/watcharr-twitch-config.png -- 34.31kb -> 26.69kb (22.2%)
/doc/docs/server_config/img/watcharr-twitch-service.png -- 10.34kb -> 8.17kb (21.04%)
/doc/docs/server_config/img/watcharr-game-details.png -- 803.03kb -> 674.97kb (15.95%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-02-16 15:52:28 +00:00
Mr 3e02af9c40 README: Update description, adding video games. 2024-02-12 01:01:37 +00:00
IRHM 2a5a470347 v1.33.0 2024-02-12 00:33:07 +00:00
IRHM 07e3f5f7a8 1.33.0-dev 2024-02-11 23:44:07 +00:00
Mr 1b83c70b5e Merge pull request #344 from sbondCo/twitch-config-modal-doc-link
TwitchModal: Add link to setup docs.
2024-02-11 23:41:09 +00:00
IRHM 1e5cdc151b TwitchModal: Add link to setup docs. 2024-02-11 23:37:53 +00:00
Mr dcb21a6e1e Merge pull request #343 from sbondCo/game-docs
Game docs
2024-02-11 23:30:41 +00:00
IRHM 0b4f3d5c97 Create game setup guide
i just realised screenshots are in dark mode, not matching the others in another guide, but it's too late to change em cuz what's done is done.
2024-02-11 23:29:14 +00:00
IRHM 0d8ea359e4 doc: Update docusaurus to v3 2024-02-11 22:41:51 +00:00
Mr 1c44b1812a Merge pull request #342 from sbondCo/games
Games
2024-02-11 21:56:58 +00:00
IRHM 3d5cf70ec0 FilterMenu: Change held to on hold 2024-02-10 20:26:35 +00:00
IRHM f1f26d0f57 Dockerfile: Copy game module 2024-02-10 19:54:18 +00:00
IRHM e3fe047afd follow: Fix get watched item logic for followed thoughts
Before it could get two entries if user has two entries that had same id number for content and game ids.
2024-02-10 17:50:10 +00:00
IRHM 4afc32f9d1 FilterMenu: Fix type styling 2024-02-10 17:49:10 +00:00
IRHM 7ea78f2d57 watched: preload game when getting public list 2024-02-10 17:31:10 +00:00
IRHM 6092410b20 Followed thoughts for games 2024-02-10 17:28:29 +00:00
IRHM 8f5293a769 Remove game filter if games disabled, update status filters for games 2024-02-10 16:48:25 +00:00
IRHM 6d25356d30 WatchedList: Fix type filtering 2024-02-10 16:18:14 +00:00
IRHM dbd86b89c7 FilterMenu: Add Game type 2024-02-10 16:18:07 +00:00
IRHM bf0b4fbb6a game: remove log of credentials 2024-02-10 15:56:35 +00:00
IRHM 5585053b85 igdb token refresh logic 2024-02-10 15:53:30 +00:00
IRHM a61b82e9af Search: Fix if games, catch search requests separately 2024-02-08 22:55:30 +00:00
IRHM d4fe6d3653 Search: Remove unused interface, remove comment 2024-02-08 22:45:30 +00:00
IRHM 6d2ab6e7e4 Search: Only search movies/tv if games disabled 2024-02-08 22:44:46 +00:00
IRHM c5e6ae702a Poster: Don't try loading image unless we have a poster path 2024-02-08 22:44:27 +00:00
IRHM 2a9b0443f5 Tv: Fix wListItem find 2024-02-08 00:10:55 +00:00
IRHM e1e933af16 Fix Status 2024-02-08 00:08:39 +00:00
IRHM a5fbecd32a Remove unneeded comments 2024-02-07 23:54:32 +00:00
IRHM 98006efb03 Games: Fix similar games having wrong status/rating 2024-02-07 23:49:11 +00:00
IRHM 926b0f8267 GamePoster: Use blurhash when available for poster loader 2024-02-07 23:46:59 +00:00
IRHM cc3f21aa93 Cache game covers 2024-02-07 22:47:16 +00:00
IRHM e311bcd901 Display status differently for games
Created toUnderstandableStatus and toUnderstandableWatchedStatus helpers to convert statuses into readable.
2024-02-06 23:56:49 +00:00
IRHM eb66baba80 Routes: Remove testing cache exp time for game routes 2024-02-03 21:41:26 +00:00
IRHM 09795b4da3 Game: Display similar games 2024-02-03 21:41:03 +00:00
IRHM 8088184e6b Create getPlayedDependedProps for searched game posters 2024-02-03 21:09:04 +00:00
IRHM 1b6c56dd8f sorta finalize gameposter on own watched list 2024-02-03 21:00:26 +00:00
IRHM 86195eb682 Game: Add check before trying to iterate over genres and modes
and show 'Unknown' message if we have none available
2024-02-03 20:38:12 +00:00
IRHM f40ffa124c GamePoster: Accept generic props so we can reuse on watched list 2024-02-03 20:38:12 +00:00
IRHM 704b6380c7 Game: Use cover if no artwork available for backdrop 2024-02-03 20:38:12 +00:00
IRHM b434665caf fix content undefined issues 2024-02-03 20:38:12 +00:00
IRHM 3554d4bc60 updating games watched item 2024-02-03 20:38:12 +00:00
IRHM 917ddd5401 fix profile stats
since Content pointer stored now
2024-02-03 20:38:12 +00:00
IRHM e7cf21fdb2 basic adding games to list 2024-02-03 20:33:52 +00:00
IRHM c932bd188a basic game detail page 2024-02-03 20:32:44 +00:00
IRHM e5d17bee81 game search ep 2024-02-03 20:31:45 +00:00
IRHM 65836a1654 idk if this works
bakup the beginnings of game support
2024-02-03 20:31:45 +00:00
IRHM 0f7bdaffbb v1.32.1 2024-02-03 15:16:09 +00:00
IRHM 30dae4c5c9 1.32.1-dev1 2024-02-03 14:57:28 +00:00
Mr f67d1641db Merge pull request #336 from sbondCo/awesome-stuff-too
Fix movary importing and include previously watched filter
2024-02-03 14:54:39 +00:00
IRHM 0ff02e00c9 WatchedList: Fix type filter when finished status enabled 2024-02-03 13:37:24 +00:00
IRHM 194081ac2f Profile: get profile stats again after updating includepreviouslywatched setting 2024-02-03 13:34:24 +00:00
IRHM 896f80ebd6 profile: Include previously watched in stats if enabled by user 2024-02-03 13:16:55 +00:00
IRHM 862279e914 import: Fix customDates being the same on all dateswatched activity
pointer value being updated each iteration, so we end up using same date for all object in the array
2024-02-03 12:51:17 +00:00
IRHM bdf5cf54af 1.32.1-dev 2024-02-03 02:53:16 +00:00
Mr 4323ab7086 Merge pull request #335 from sbondCo/amazing-stuff
https://github.com/sbondCo/Watcharr/issues/332#issuecomment-1923230972
2024-02-03 02:51:26 +00:00
IRHM 555b237ed0 WatchedList: Filter on sort change, add STATUS_CHANGED as activity that could make show watched 2024-02-03 02:37:58 +00:00
IRHM 5146af2672 Filtering: Add includePreviouslyWatched setting 2024-02-03 02:13:46 +00:00
IRHM 4d3da51ae7 Movary Import: Keep history data if show is also on watchlist 2024-02-02 21:35:54 +00:00
IRHM 9cba877fe6 Movary Import: Set latest watched date as watched entry CreatedAt 2024-02-02 21:21:21 +00:00
IRHM 924ffcb33f v1.32.0 2024-02-02 02:24:50 +00:00
IRHM b638062d36 1.32.0-dev 2024-02-02 01:47:20 +00:00
dependabot[bot] da64b5ca59 ui: bump svelte-check from 3.6.2 to 3.6.3 (#317)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.6.2 to 3.6.3.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.6.2...svelte-check-3.6.3)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:30:51 +00:00
dependabot[bot] 8f2d9b0984 ui: bump @typescript-eslint/parser from 6.18.0 to 6.20.0 (#328)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.18.0 to 6.20.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.20.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:30:34 +00:00
dependabot[bot] c2e85ff8b3 server: bump gorm.io/gorm from 1.25.5 to 1.25.6 in /server (#327)
Bumps [gorm.io/gorm](https://github.com/go-gorm/gorm) from 1.25.5 to 1.25.6.
- [Release notes](https://github.com/go-gorm/gorm/releases)
- [Commits](https://github.com/go-gorm/gorm/compare/v1.25.5...v1.25.6)

---
updated-dependencies:
- dependency-name: gorm.io/gorm
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:29:18 +00:00
dependabot[bot] 3c9d074169 ui: bump @typescript-eslint/eslint-plugin from 6.18.0 to 6.20.0 (#330)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.18.0 to 6.20.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.20.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:28:38 +00:00
dependabot[bot] 7a254222a1 ui: bump @sveltejs/adapter-node from 2.0.2 to 4.0.1 (#331)
Bumps [@sveltejs/adapter-node](https://github.com/sveltejs/kit/tree/HEAD/packages/adapter-node) from 2.0.2 to 4.0.1.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/adapter-node/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/adapter-node@4.0.1/packages/adapter-node)

---
updated-dependencies:
- dependency-name: "@sveltejs/adapter-node"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:26:35 +00:00
dependabot[bot] 4662977677 ui: bump prettier from 3.1.1 to 3.2.4 (#322)
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.1 to 3.2.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.1...3.2.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:25:01 +00:00
dependabot[bot] 798c80f69b ui: bump vite from 5.0.11 to 5.0.12 (#320)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.11 to 5.0.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.0.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.12/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-02 01:24:29 +00:00
Mr d8e4182824 Merge pull request #334 from sbondCo/movary-import
Movary Importing
2024-02-02 01:21:49 +00:00
IRHM ac9fb8c28d import/process: Update importText for TMDb to TMDB 2024-02-02 01:11:32 +00:00
IRHM d0b1b9ed17 import: add error to sucimp log 2024-02-02 00:56:16 +00:00
IRHM 548643327d Support importing status, thoughts and datesWatched
for movary import
2024-02-02 00:45:36 +00:00
IRHM a86d2a218a wip build movary import list 2024-02-02 00:18:41 +00:00
IRHM 21ecc4cbbf wip movary importing from the 3 export files 2024-02-01 01:32:01 +00:00
Mr 2e19cad3a8 Merge pull request #314 from sbondCo/update-cache-on-req
Update content table on request
2024-01-13 22:47:48 +00:00
IRHM 8bffc7380e saveContent: remove test log and debug on Updates
and update its error handling, copied from below, removed case not needed for updating... or even that code anymore but whatevz
2024-01-13 22:44:02 +00:00
IRHM 96720caace saveContent: Support onlyUpdate parameter 2024-01-13 17:40:33 +00:00
IRHM 6c130952d7 update cache when content is freshly fetched 2024-01-11 02:29:46 +00:00
IRHM 3c1fc0e950 split out content caching for reuse 2024-01-11 02:05:35 +00:00
IRHM d95990fc56 v1.31.1 2024-01-09 20:45:59 +00:00
dependabot[bot] 9d0a433121 Bump follow-redirects from 1.15.3 to 1.15.4 in /doc (#311)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.3 to 1.15.4.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.3...v1.15.4)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-09 20:44:50 +00:00
Mr 2bd02439c2 Support ARM64 Arch (#310)
* dev workflow: Add arm64 platform build

in sequence because paralellising the jobs looks hard

* dev action: remove `dev` tag

I think it would be better to just refer to them with their sha256 instead + i dont like that when a `dev` image is built, the `dev` tag goes to top of list of tagged images

* Revert "dev action: remove `dev` tag"

This reverts commit 0179f616fa.

nevermind, that is not a thing.

* release action: add arm platform
2024-01-09 20:42:30 +00:00
dependabot[bot] 6a1fdd69e3 ui: bump @sveltejs/kit from 2.0.7 to 2.0.8 (#308)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.0.7 to 2.0.8.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.0.8/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:35:40 +00:00
dependabot[bot] fb7dc5d0f8 ui: bump axios from 1.6.3 to 1.6.5 (#304)
Bumps [axios](https://github.com/axios/axios) from 1.6.3 to 1.6.5.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.3...v1.6.5)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:34:12 +00:00
dependabot[bot] c3d480d47b ui: bump @sveltejs/kit from 2.0.6 to 2.0.7 (#305)
Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.0.7/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:33:39 +00:00
dependabot[bot] 274c97bcdd ui: bump @typescript-eslint/eslint-plugin from 6.17.0 to 6.18.0 (#306)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.17.0 to 6.18.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.18.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:33:20 +00:00
dependabot[bot] d6b5fc9cd3 server: bump golang.org/x/crypto from 0.17.0 to 0.18.0 in /server (#303)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.17.0 to 0.18.0.
- [Commits](https://github.com/golang/crypto/compare/v0.17.0...v0.18.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:32:51 +00:00
dependabot[bot] 4de9865a39 ui: bump vite from 5.0.10 to 5.0.11 (#307)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.0.10 to 5.0.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.0.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-08 20:32:22 +00:00
Mr d278f2cf98 Merge pull request #302 from sbondCo/watchtime
Watchtime Profile Stat
2024-01-07 23:41:21 +00:00
IRHM 5aec83c2f0 Profile: Badly implement show watchtime
for now... hopefully
2024-01-07 23:36:56 +00:00
IRHM fbda3d4b9b Stat: Add disclaimer prop 2024-01-07 23:15:52 +00:00
IRHM dde8fad248 Profile: Implement movies watched runtime stat 2024-01-07 21:52:26 +00:00
IRHM e8309568bc v1.31.0 2024-01-07 17:50:51 +00:00
IRHM 997e3afb7e 1.31.0-dev 2024-01-07 01:07:39 +00:00
Mr 891fc51bcd Input changed validation (#299)
* thoughts change validation

* Rating & Status change validation

* Poster: status change validation
2024-01-07 01:04:00 +00:00
dependabot[bot] ebe42f1d34 ui: bump @typescript-eslint/parser from 6.16.0 to 6.18.0 (#298)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.16.0 to 6.18.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.18.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-06 18:43:04 +00:00
Mr b799c660ca Merge pull request #294 from sbondCo/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-6.17.0
ui: bump @typescript-eslint/eslint-plugin from 6.16.0 to 6.17.0
2024-01-06 18:41:14 +00:00
Mr a10f570983 Merge pull request #293 from sbondCo/dependabot/npm_and_yarn/axios-1.6.3
ui: bump axios from 1.6.2 to 1.6.3
2024-01-06 18:40:51 +00:00
Mr f205f96010 Merge pull request #297 from sbondCo/detail-view
Detail view
2024-01-06 18:39:49 +00:00
IRHM 735a98e0b2 Poster: Make extra details box more opaque
and use same color for both themes
2024-01-06 17:45:47 +00:00
IRHM f87aa70a3b Poster: fix extra details colors and spacing 2024-01-06 17:41:13 +00:00
IRHM a2dc418ee6 Poster: Hide empty extra-details 2024-01-05 19:35:39 +00:00
IRHM 4c7982f7be improve getLatestWatchedSeason
it could be improved more though
2024-01-05 06:17:41 +00:00
IRHM 43d603c212 Detailed Poster: Use real data 2024-01-05 05:39:49 +00:00
IRHM ebf623ed1b Redesign poster extra details and allow configuring displayed data 2024-01-05 04:53:00 +00:00
IRHM d925519beb Create DetailedPoster 2024-01-03 23:13:38 +00:00
dependabot[bot] 07b72452e8 ui: bump @typescript-eslint/eslint-plugin from 6.16.0 to 6.17.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.16.0 to 6.17.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.17.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 19:22:43 +00:00
dependabot[bot] ab4781bec0 ui: bump axios from 1.6.2 to 1.6.3
Bumps [axios](https://github.com/axios/axios) from 1.6.2 to 1.6.3.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.2...v1.6.3)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-01 19:21:56 +00:00
IRHM 7719b6336c v1.30.0 2024-01-01 02:50:19 +00:00
IRHM e749e94bfb 1.30.0-dev 2023-12-31 20:23:17 +00:00
IRHM c1ceb17ada Rating: Fix text not updating if network request resolves after we hoverEnd 2023-12-31 20:18:39 +00:00
IRHM 91dd00b556 routes: Set AuthRequired middleware once for auth routes that require it
indenting authd routes definitely helps with readability
2023-12-30 17:37:59 +00:00
Mr 60e873b4c0 Merge pull request #292 from sbondCo/content-stars-text
Content rating text follow hovered star
2023-12-30 05:02:28 +00:00
IRHM 4333591c9c Rating: Add transition to transform
to make it smoother when moving to initial close rating to default middle pos.
2023-12-30 04:53:10 +00:00
IRHM 462a46aabb Rating: Fix causing page overflow on small viewports 2023-12-30 04:52:15 +00:00
IRHM c766d68d49 Rating: Center desc text and make it follow hovered star 2023-12-29 03:04:56 +00:00
Mr 6b7c38aa7a Merge pull request #291 from sbondCo/exportwl
Export Watched List
2023-12-29 02:08:48 +00:00
IRHM 367c79f29c Change empty watch list error text 2023-12-29 02:07:35 +00:00
IRHM c59eb82a1a dont try saving an empty watch list 2023-12-29 02:03:46 +00:00
IRHM 3d40e5554f change suggested export name to watcharr-export.json 2023-12-29 01:58:49 +00:00
IRHM 75f091cfa0 Basic exporting watched list support 2023-12-29 01:55:07 +00:00
Mr 1d0abe4982 Merge pull request #290 from iamericfletcher/user-password-change-issue-279
Implement Change Password Feature (Issue #279)
2023-12-28 22:24:01 +00:00
Mr f8490481a5 Merge branch 'dev' into user-password-change-issue-279 2023-12-28 22:16:54 +00:00
IRHM 1adc56f965 userChangePassword: Select only pass from db 2023-12-28 22:12:50 +00:00
IRHM 8861233aaa Move GetPassArgonParams, Remove DEBUG, add use of ternary operator 2023-12-28 22:01:58 +00:00
iamericfletcher 3c4718bc45 Update error handling in changeUserPassword and display of error messages in pwChangeModal
The 'changeUserPassword' callback is updated such that error messages are passed back to the calling function in `PwChangeModal` allowing errors to show up in the same place.
2023-12-28 16:19:31 -05:00
iamericfletcher f6fa9e7666 Refactor ArgonParams to a function for register and userChangePassword in auth.go 2023-12-28 15:37:11 -05:00
iamericfletcher 98a7c370a8 Removed unneeded else branches in auth.go 2023-12-28 14:59:57 -05:00
iamericfletcher 37f9bffb13 Move server endpoint to /auth/change_password and update corresponding frontend API call 2023-12-28 14:52:19 -05:00
iamericfletcher a4573fe52a Move userChangePassword function to auth.go 2023-12-28 14:40:35 -05:00
iamericfletcher aeaaccda88 Remove Form.svelte and related import
Form.svelte was deleted due to not being used or needed. An associated import line in PwChangeModal.svelte has also been removed.

Additionally, setting parameter DEBUG was removed from server/config.go.
2023-12-27 17:48:18 -05:00
iamericfletcher 3a2ce0c718 Refactor password change feature and update debug messages
Removed a substantial block of commented-out code related to password changing in the `user.go` file, which simplifies the structure and readability of the code. Improved clarity and specificity of console debug/feedback messages throughout the frontend, ensuring easier debugging. Commit also includes minor code clean up in some files.
2023-12-27 17:24:05 -05:00
iamericfletcher 2ac80d0b8c Refine password change error handling and reporting
The commit delivers improved error handling and debugging information for password change functionality. Error messages now provide more detailed context on failures. Additionally, new password hashing has been added in the password change process. The user interface has also been updated to reflect specific error messages returned from the API.
2023-12-27 14:14:53 -05:00
iamericfletcher 3ff5d9836b Refactor user password change functionality
The password change function now involves first querying user data from the database, before checking the validity of their old password. Logic has been updated on both frontend and backend code. Errors have been added for handling instances where password comparison, user retrieval or other processes fail.
2023-12-27 10:08:06 -05:00
iamericfletcher e6d486c2a2 Implement password change feature
A new feature has been added that allows users to change their password. This includes updates in several different files to accommodate this new functionality. Changes include adding a new API endpoint, creating a new function to handle the password change request, and removing the username field from the ChangePasswordForm interface.
2023-12-26 20:43:11 -05:00
iamericfletcher c4e591e2f2 Improve form validation in PwChangeModal.svelte
Updated form validation to check for missing inputs and ensure consistency between new password and re-entered new password. Enhanced logging for better status tracking and error reporting during form validation process.
2023-12-26 12:36:32 -05:00
iamericfletcher 0eb1a88103 Refactoring password button in PwChangeModal to be in line with button in SonarrModal 2023-12-26 11:58:47 -05:00
IRHM ffe6afe2d2 v1.29.0 2023-12-26 14:46:15 +00:00
iamericfletcher c767572905 Refactoring password change modal form 2023-12-26 09:36:24 -05:00
IRHM da803220e4 1.29.0-dev1 2023-12-26 14:23:57 +00:00
IRHM 5c4f023fd8 FollowedThoughts: Fix dark theme status icon fill 2023-12-26 14:23:31 +00:00
IRHM 7e2e64f394 1.29.0-dev 2023-12-26 14:14:07 +00:00
dependabot[bot] 9609909122 ui: bump @typescript-eslint/eslint-plugin from 6.13.0 to 6.16.0 (#285)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.13.0 to 6.16.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.16.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:09:02 +00:00
dependabot[bot] 7522784f18 ui: bump @typescript-eslint/parser from 6.15.0 to 6.16.0 (#284)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.15.0 to 6.16.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.16.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:07:09 +00:00
dependabot[bot] 8d5379e38b ui: bump svelte-preprocess from 5.1.1 to 5.1.3 (#283)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.1 to 5.1.3.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.1...v5.1.3)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:06:43 +00:00
dependabot[bot] 8065349a5c ui: bump eslint from 8.55.0 to 8.56.0 (#286)
Bumps [eslint](https://github.com/eslint/eslint) from 8.55.0 to 8.56.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.55.0...v8.56.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:04:50 +00:00
dependabot[bot] 26c84ae7c7 ui: bump prettier from 3.1.0 to 3.1.1 (#287)
Bumps [prettier](https://github.com/prettier/prettier) from 3.1.0 to 3.1.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.1.0...3.1.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-26 14:04:07 +00:00
Mr f190bffdd0 Merge pull request #288 from sbondCo/public-thoughts
Public thoughts
2023-12-26 06:36:08 +00:00
IRHM e0f8d753bf follow: remove debug log 2023-12-26 06:27:22 +00:00
IRHM b6223c7ac8 FollowedThoughts: Ensure status icon is always the same size 2023-12-26 06:08:12 +00:00
IRHM ffd8bbe442 expose new privateThoughts setting to user 2023-12-26 05:45:54 +00:00
IRHM bc86b8f6b4 FollowedThoughts: Add extra space between status and rating 2023-12-26 05:26:12 +00:00
IRHM 0cb5c01b5e FollowedThoughts: Add status icon to cards 2023-12-26 05:22:39 +00:00
IRHM 0393966ecc getting and showing followed thoughts on content
Show followed users rating and thoughts on content
2023-12-26 05:04:41 +00:00
iamericfletcher b3ba00cd0d Added beginning stages of password change functionality in user profile
A new interface ChangePasswordForm has been created to handle password change form data.

The 'PwChangeModal.svelte' modal allows the user to input current and new passwords for a change password request.

The user profile page has been updated to include a "Change Password" button which triggers the change password modal.
2023-12-25 21:39:54 -05:00
Mr 5084a5b724 Merge pull request #281 from iamericfletcher/feature-sort-filter-watched-lists
Enhancement: Allow filtering and sorting other watched lists #276
2023-12-24 23:43:30 +00:00
iamericfletcher 43a484f949 Remove unused 'watchedListErrorOccurred' 2023-12-24 12:59:11 -05:00
iamericfletcher 8434e852ce Enhancement: Allow filtering and sorting other watched lists #276
Added a new store variable 'watchedListErrorOccurred' set to true if `getPublicWatchedList()` promise errors.

The aim is that the filter and sort functionality only show if the watched list loads in successfully.
2023-12-24 09:02:35 -05:00
Mr e541dc5cdb Merge pull request #280 from sbondCo/fix-dupe-tmdbids
Fix content with same tmdb ids
2023-12-23 20:29:41 +00:00
IRHM 4a20819803 addWatched: Check content type when fetching 2023-12-23 20:23:22 +00:00
IRHM 9a568b9da7 add content type to .find of wListItem in content pages 2023-12-23 20:15:13 +00:00
dependabot[bot] 9176d97eee ui: bump @sveltejs/kit from 1.27.6 to 2.0.6 (#278)
* ui: bump @sveltejs/kit from 1.27.6 to 2.0.6

Bumps [@sveltejs/kit](https://github.com/sveltejs/kit/tree/HEAD/packages/kit) from 1.27.6 to 2.0.6.
- [Release notes](https://github.com/sveltejs/kit/releases)
- [Changelog](https://github.com/sveltejs/kit/blob/main/packages/kit/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/kit/commits/@sveltejs/kit@2.0.6/packages/kit)

---
updated-dependencies:
- dependency-name: "@sveltejs/kit"
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

* update @sveltejs/adapter-node, @vite-pwa/sveltekit, vite

to fix peer dep issues

* import vitePreprocess from @sveltejs/vite-plugin-svelte

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IRHM <37304121+IRHM@users.noreply.github.com>
2023-12-23 16:26:06 +00:00
dependabot[bot] 1cbc065b7b ui: bump eslint-plugin-svelte from 2.35.0 to 2.35.1 (#263)
Bumps [eslint-plugin-svelte](https://github.com/sveltejs/eslint-plugin-svelte) from 2.35.0 to 2.35.1.
- [Release notes](https://github.com/sveltejs/eslint-plugin-svelte/releases)
- [Changelog](https://github.com/sveltejs/eslint-plugin-svelte/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/eslint-plugin-svelte/compare/v2.35.0...v2.35.1)

---
updated-dependencies:
- dependency-name: eslint-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:55:52 +00:00
dependabot[bot] b4865aa789 ui: bump prettier-plugin-svelte from 3.0.3 to 3.1.2 (#262)
Bumps [prettier-plugin-svelte](https://github.com/sveltejs/prettier-plugin-svelte) from 3.0.3 to 3.1.2.
- [Changelog](https://github.com/sveltejs/prettier-plugin-svelte/blob/master/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/prettier-plugin-svelte/compare/v3.0.3...v3.1.2)

---
updated-dependencies:
- dependency-name: prettier-plugin-svelte
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:55:31 +00:00
dependabot[bot] 0c29927ef6 workflow: bump actions/setup-node from 3 to 4 (#271)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 3 to 4.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:54:54 +00:00
dependabot[bot] 75e499a070 ui: bump typescript from 5.3.2 to 5.3.3 (#264)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.2 to 5.3.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.2...v5.3.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:54:26 +00:00
dependabot[bot] fe589e7de5 workflow: bump actions/checkout from 3 to 4 (#272)
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:50:41 +00:00
dependabot[bot] 9d7d6b0e3a ui: bump @typescript-eslint/parser from 6.13.1 to 6.15.0 (#274)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.13.1 to 6.15.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.15.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-23 15:49:00 +00:00
Mr 5fb720cca4 Merge pull request #275 from sbondCo/dependabot/go_modules/server/golang.org/x/crypto-0.17.0
server: bump golang.org/x/crypto from 0.16.0 to 0.17.0 in /server
2023-12-23 15:48:20 +00:00
Mr 2084b42098 Merge pull request #277 from sbondCo/fix-tooltips
Fix tooltips on scroll and from causing overflow on window resize
2023-12-23 15:43:52 +00:00
IRHM d26db7759f resetTooltipPos on window resize to fix overflow
resizing window smaller if tooltip is somewhere can cause overflow on page, reset its pos to top left on resize to avoid this.
2023-12-23 15:40:59 +00:00
IRHM 89311d4487 Revert "make tooltips fixed position"
This reverts commit 4d65a16584.
2023-12-23 15:36:29 +00:00
dependabot[bot] 72dca29d69 server: bump golang.org/x/crypto from 0.16.0 to 0.17.0 in /server
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-18 19:50:49 +00:00
imgbot[bot] 36b3bde00e [ImgBot] Optimize images (#268)
*Total -- 5,368.58kb -> 4,578.90kb (14.71%)

/static/logo-sqre.png -- 10.43kb -> 4.68kb (55.15%)
/screenshot/user-profile.png -- 47.51kb -> 36.22kb (23.78%)
/screenshot/content-details-page-dark.png -- 523.03kb -> 429.56kb (17.87%)
/screenshot/content-details-page.png -- 370.65kb -> 304.62kb (17.81%)
/static/logo-sqre-144.png -- 6.43kb -> 5.39kb (16.28%)
/static/logo-sqre-192.png -- 8.61kb -> 7.24kb (15.97%)
/screenshot/homepage-poster-hover.png -- 877.03kb -> 751.92kb (14.27%)
/screenshot/homepage-poster-change-status.png -- 865.78kb -> 743.03kb (14.18%)
/screenshot/discover-page.png -- 765.65kb -> 660.56kb (13.73%)
/screenshot/homepage.png -- 945.27kb -> 816.18kb (13.66%)
/screenshot/homepage-dark.png -- 948.18kb -> 819.52kb (13.57%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
Co-authored-by: ImgBotApp <ImgBotHelp@gmail.com>
2023-12-10 17:34:04 +00:00
IRHM 425f0da422 v1.28.0 2023-12-10 03:02:38 +00:00
IRHM 6773736113 1.28.0-dev4 2023-12-10 02:57:23 +00:00
IRHM 51ec248b67 add short_name to pwa manifest 2023-12-10 02:57:00 +00:00
IRHM 702084c83c 1.28.0-dev3 2023-12-10 02:51:51 +00:00
IRHM 2aa5f0c4fa Add name, description, bg col and category to pwa manifest config
the lowercase name is too annoying, must have the capital W. Hopefully the bg colour can fix issues where the icon isn't scaled up, but white was shown so it looked odd. That may mask the issue.
2023-12-10 02:51:22 +00:00
IRHM 359f5a372a 1.28.0-dev2 2023-12-10 02:34:53 +00:00
Mr 03a8397498 Merge pull request #267 from sbondCo/new-pwa-icons
new icons for pwa
2023-12-10 02:34:02 +00:00
IRHM 034e6b10e7 new icons for pwa 2023-12-10 02:31:38 +00:00
IRHM 8100c5f9a9 1.28.0-dev1 2023-12-09 23:23:43 +00:00
Mr 95c8f2d5b0 Merge pull request #266 from sbondCo/prs
PR Template and test workflow
2023-12-09 23:15:35 +00:00
IRHM be29170f6d Create test-pr.yml
to check for formatting / linting errors
2023-12-09 23:12:59 +00:00
IRHM f02699b089 fix @html linting errors
the contents come from trusted sources so we should be okay
2023-12-09 23:08:37 +00:00
IRHM 82b7a3d0f6 fix css linting errors 2023-12-09 22:57:46 +00:00
IRHM 8608ad4fd6 format readme 2023-12-09 22:51:17 +00:00
IRHM ab96107797 Create PULL_REQUEST_TEMPLATE.md 2023-12-09 22:47:19 +00:00
Mr a38291c9b9 Merge pull request #260 from sbondCo/dokrfl
Fix dockerfile server build
2023-12-09 22:43:28 +00:00
IRHM 67cfd03a1d run formatter 2023-12-09 22:40:18 +00:00
IRHM c4fa99b5aa Fix dockerfile server build for musl
Workaround from gh issue seems to fix the issue of build failing
2023-12-09 22:28:22 +00:00
Mr 1d904a7188 Merge pull request #259 from ishaanparlikar/feature/pwa
Feature/pwa
2023-12-09 19:31:03 +00:00
ishaanparlikar be8cee5378 removed jsdoc type inference 2023-12-10 00:31:47 +05:30
ishaanparlikar 128a3d4cc6 removed workbox dependancy 2023-12-10 00:29:51 +05:30
ishaanparlikar 610fb3d0c0 fixed indentation 2023-12-09 12:06:59 +05:30
ishaanparlikar 2dd01f296b Feature: Added PWA support for Watcharr #225 2023-12-09 12:02:20 +05:30
Mr 6e118085a9 Merge pull request #258 from sbondCo/episode-overview
SeasonsListEpisode: Fix spoilers
2023-12-09 02:21:26 +00:00
IRHM 2f3400d4d7 SeasonsListEpisode: Fix spoilers 2023-12-09 02:20:53 +00:00
Mr da787741d8 Merge pull request #257 from sbondCo/episode-overview
Episode overview and runtime
2023-12-09 02:16:51 +00:00
IRHM 97bdf2f89c SeasonsListEpisode: Quick fix patch hack code and reimplement fix and repair the star position to fix it 2023-12-09 02:15:30 +00:00
IRHM a8c8431715 SeasonsListEpisode: Add episode runtimes 2023-12-09 02:11:15 +00:00
IRHM 18e3e333c6 SeasonsListEpisode: Add overviews 2023-12-09 02:01:29 +00:00
Mr f7ddb1e14b Merge pull request #255 from ishaanparlikar/fix/#217
Hiding rating when hide spoiler is checked in profile
2023-12-08 22:34:32 +00:00
ishaanparlikar e5c513161e Merge branch 'dev' of https://github.com/ishaanparlikar/Watcharr into dev 2023-12-08 21:49:27 +05:30
ishaanparlikar 55235839ff Hiding rating when hide spoiler is checked in profile 2023-12-08 21:41:21 +05:30
dependabot[bot] e5536c5429 ui: bump svelte-preprocess from 5.1.0 to 5.1.1 (#247)
Bumps [svelte-preprocess](https://github.com/sveltejs/svelte-preprocess) from 5.1.0 to 5.1.1.
- [Changelog](https://github.com/sveltejs/svelte-preprocess/blob/main/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte-preprocess/compare/v5.1.0...v5.1.1)

---
updated-dependencies:
- dependency-name: svelte-preprocess
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 21:02:53 +00:00
dependabot[bot] 02fc0ccb94 ui: bump eslint-config-prettier from 9.0.0 to 9.1.0 (#248)
Bumps [eslint-config-prettier](https://github.com/prettier/eslint-config-prettier) from 9.0.0 to 9.1.0.
- [Changelog](https://github.com/prettier/eslint-config-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-config-prettier/compare/v9.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: eslint-config-prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 21:00:25 +00:00
dependabot[bot] 83dcb85e5b server: bump github.com/golang-jwt/jwt/v5 from 5.1.0 to 5.2.0 in /server (#245)
Bumps [github.com/golang-jwt/jwt/v5](https://github.com/golang-jwt/jwt) from 5.1.0 to 5.2.0.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v5.1.0...v5.2.0)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v5
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 21:00:05 +00:00
dependabot[bot] 155cd21b40 ui: bump svelte from 4.2.7 to 4.2.8 (#246)
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.7 to 4.2.8.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/svelte@4.2.8/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.8/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:57:33 +00:00
dependabot[bot] f06da5b26e ui: bump eslint from 8.54.0 to 8.55.0 (#250)
Bumps [eslint](https://github.com/eslint/eslint) from 8.54.0 to 8.55.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.54.0...v8.55.0)

---
updated-dependencies:
- dependency-name: eslint
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:48:14 +00:00
dependabot[bot] e00bfa0187 ui: bump vite from 4.5.0 to 4.5.1 (#251)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.0 to 4.5.1.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.1/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.1/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:45:08 +00:00
dependabot[bot] 4a008e4c2f ui: bump @types/papaparse from 5.3.11 to 5.3.14 (#249)
Bumps [@types/papaparse](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/papaparse) from 5.3.11 to 5.3.14.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/papaparse)

---
updated-dependencies:
- dependency-name: "@types/papaparse"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-07 20:35:37 +00:00
Mr 8c84fe29f9 Merge pull request #254 from sbondCo/fix-tooltip-overflow
Fix tooltip and nav overflow
2023-12-07 00:10:13 +00:00
IRHM 3bd03e4e97 nav: fix overflow and improve responsiveness 2023-12-07 00:08:14 +00:00
IRHM 4d65a16584 make tooltips fixed position
to stop overflowing when page resizes
2023-12-06 20:24:56 +00:00
Mr 21b4e49aa0 Merge pull request #253 from sbondCo/sort-indicators
Basic sort and filter indicators
2023-12-06 20:03:45 +00:00
IRHM 1bcc521d3a Basic sort and filter indicators 2023-12-06 20:01:49 +00:00
IRHM 9fa85e3191 v1.27.0 2023-12-03 23:49:12 +00:00
IRHM 55ff90a190 1.27.0-dev1 2023-12-03 23:38:51 +00:00
IRHM 2bf7639ecf fix user cards on dark theme 2023-12-03 23:38:06 +00:00
IRHM 2aabd94311 1.27.0-dev 2023-12-03 23:25:07 +00:00
Mr 17a61c0248 Posters - Use cached images on watched list (#243)
* upload avatar: add comment

* Poster: Use local cached image if show is on watched list
2023-12-03 15:41:12 +00:00
IRHM a17c7a4ce5 profile: update user state when bio updates 2023-12-03 15:28:45 +00:00
IRHM f31009fa5e list: fix profile data not updating 2023-12-03 15:28:45 +00:00
IRHM 559ac8945a fix name overflows not showing ellipsis 2023-12-03 15:28:45 +00:00
IRHM 1ce7f5a938 list: center user card
think it looks better like this
2023-12-03 15:28:45 +00:00
IRHM d634956d76 list: hide bio if not set and add gap to user-row 2023-12-03 15:28:45 +00:00
IRHM 3275ad6620 list: add title of full bio 2023-12-03 15:28:45 +00:00
IRHM 3ac9e94f89 list: fix bio and clamp to 2 lines
i dont care about the newlines anymore
2023-12-03 15:28:45 +00:00
IRHM 9562b3fef0 profile: fix and add notifs to post requests 2023-12-03 15:28:45 +00:00
IRHM ae95329d06 Show avatar/bio on user page, change follow btn to an icon 2023-12-03 15:28:45 +00:00
IRHM 1e81872876 move profile avatar to new UserAvatar component 2023-12-03 15:28:45 +00:00
IRHM 90eb422c9d fix user search and display proper errors 2023-12-03 15:28:45 +00:00
IRHM 3bfcb741f1 tasks: setup daily tasks interval 2023-12-03 15:28:45 +00:00
IRHM e04d72e565 images: cleanup: change debug to info logs 2023-12-03 15:28:45 +00:00
IRHM 99aa4e564a support bio 2023-12-03 15:28:45 +00:00
IRHM eac5c5a91e images: update comment 2023-12-03 15:28:45 +00:00
IRHM 82e9a4c0ea check file type is supported image 2023-12-03 15:28:45 +00:00
IRHM 4dd494dc80 profile: update user avatar when changed 2023-12-03 15:28:45 +00:00
IRHM 4422e83cd7 wip cleanup images task 2023-12-03 15:28:45 +00:00
IRHM eec6e00dac profile: remove canvas after image load
and remove z-indexes on img and canvas
2023-12-03 15:28:45 +00:00
IRHM 579736eb10 perform blurhash magic
probs wanna remove that canvas after img loads
2023-12-03 15:28:45 +00:00
IRHM 06fadeffa1 profile: show avatar 2023-12-03 15:28:45 +00:00
IRHM 97c87ecbf0 wip setting user avatar
uploading and setting working, need to display
2023-12-03 15:28:45 +00:00
Mr e55593d62b Nav: Hide buttons when searchbar is focused (#241) 2023-11-29 16:56:15 +00:00
dependabot[bot] 2a3b5c8746 server: bump github.com/gin-contrib/cors from 1.4.0 to 1.5.0 in /server (#232)
Bumps [github.com/gin-contrib/cors](https://github.com/gin-contrib/cors) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/gin-contrib/cors/releases)
- [Changelog](https://github.com/gin-contrib/cors/blob/master/.goreleaser.yaml)
- [Commits](https://github.com/gin-contrib/cors/compare/v1.4.0...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/gin-contrib/cors
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:04:04 +00:00
dependabot[bot] 52097df861 ui: bump prettier from 3.0.3 to 3.1.0 (#231)
Bumps [prettier](https://github.com/prettier/prettier) from 3.0.3 to 3.1.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.0.3...3.1.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:03:19 +00:00
dependabot[bot] 92637aef3d ui: bump axios from 1.6.1 to 1.6.2 (#230)
Bumps [axios](https://github.com/axios/axios) from 1.6.1 to 1.6.2.
- [Release notes](https://github.com/axios/axios/releases)
- [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md)
- [Commits](https://github.com/axios/axios/compare/v1.6.1...v1.6.2)

---
updated-dependencies:
- dependency-name: axios
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:02:53 +00:00
dependabot[bot] 02f537c5bf ui: bump svelte-check from 3.6.0 to 3.6.2 (#229)
Bumps [svelte-check](https://github.com/sveltejs/language-tools) from 3.6.0 to 3.6.2.
- [Release notes](https://github.com/sveltejs/language-tools/releases)
- [Commits](https://github.com/sveltejs/language-tools/compare/svelte-check-3.6.0...svelte-check-3.6.2)

---
updated-dependencies:
- dependency-name: svelte-check
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:02:08 +00:00
dependabot[bot] c63ab3cddf server: bump golang.org/x/crypto from 0.15.0 to 0.16.0 in /server (#233)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.15.0 to 0.16.0.
- [Commits](https://github.com/golang/crypto/compare/v0.15.0...v0.16.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:01:33 +00:00
dependabot[bot] cf7457a671 ui: bump @typescript-eslint/parser from 6.11.0 to 6.13.1 (#240)
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.11.0 to 6.13.1.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.13.1/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 14:00:19 +00:00
dependabot[bot] 095cc21ea4 ui: bump @typescript-eslint/eslint-plugin from 6.11.0 to 6.13.0 (#227)
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.11.0 to 6.13.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.13.0/packages/eslint-plugin)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-11-29 13:57:56 +00:00
IRHM 983f4efa95 v1.26.0 2023-11-28 02:53:50 +00:00
IRHM 4573d69bbf 1.26.0-dev2 2023-11-28 02:45:19 +00:00
IRHM 07cbcb4d41 fix search page padding/margin 2023-11-28 02:44:44 +00:00
IRHM 71e31df2bf 1.26.0-dev1 2023-11-28 02:27:26 +00:00
IRHM f969c591c1 nav menus: fix themeing on arrow 2023-11-28 02:26:50 +00:00
IRHM 6d1e95ff5c 1.26.0-dev 2023-11-28 02:19:02 +00:00
Mr e7a6a888b1 watched: changed movie default status to FINISHED (#239)
* watched: changed movie default status to FINISHED

when adding content to list by rating instead of giving status.

* update comment
2023-11-28 02:16:19 +00:00
Mr ab9fdddc14 Merge pull request #234 from sbondCo/following
Following Users & Fix user lists posters
2023-11-28 02:02:52 +00:00
IRHM 6160e906df poster: fix interaction-disabled for user lists 2023-11-28 01:49:18 +00:00
IRHM dafac30df1 hide following tooltip if menu open 2023-11-26 19:32:45 +00:00
IRHM e98b6a383d style user page 2023-11-26 19:30:50 +00:00
IRHM 679168d88c unfollow, responsive height sub menus 2023-11-26 19:11:11 +00:00
IRHM 8679cdf0c6 tsconfig: include env.d.ts 2023-11-26 15:06:27 +00:00
IRHM 27e2e98ca3 move sort menu and filter menu to their own components 2023-11-26 13:40:28 +00:00
IRHM 3f90b2f0ca sub menu arrows to icon 2023-11-26 13:28:38 +00:00
IRHM dfa4e81dc2 basic following sub menu 2023-11-26 02:33:14 +00:00
IRHM 3d3bfa50bb wip get followers and add new following icon
also fix compass icon (not filled) and fix tooltips
2023-11-25 21:47:39 +00:00
IRHM f4a4b7c6d7 improve search resposiveness for large screens 2023-11-25 21:21:28 +00:00
IRHM db1b1bee65 wip follow routes 2023-11-25 14:32:19 +00:00
IRHM 53aa76f009 translate errors with gorm
and instead of using strings.Contains, we can compare to gorms error now
2023-11-25 14:31:50 +00:00
IRHM 24fe7f4984 create follows table 2023-11-25 13:31:39 +00:00
IRHM 99d9d4866a Cleanup search page and userslist
by cleanup i mean make look nicer
2023-11-21 20:43:50 +00:00
190 changed files with 28634 additions and 5323 deletions
+5
View File
@@ -0,0 +1,5 @@
<!-- Make sure your code is formatted by running `npm run format` or using prettier manually. -->
### Changes made
<!-- Describe changes made here. If changes are visual a screenshot could be useful! -->
+1 -1
View File
@@ -9,7 +9,7 @@ updates:
interval: "weekly"
commit-message:
prefix: "ui"
# Server
- package-ecosystem: "gomod"
directory: "/server"
+25 -5
View File
@@ -1,10 +1,13 @@
# Multi arch support doc/guide:
# https://docs.docker.com/build/ci/github-actions/multi-platform/
name: Development Watcharr Image Release
on:
workflow_dispatch:
env:
image: ghcr.io/sbondco/watcharr
image: sbondco/watcharr
jobs:
release-dev-image:
@@ -18,18 +21,35 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Log in to the Container registry
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: sbondco
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.image }}:dev
labels: ${{ env.image }}:${{ github.sha }}
tags: |
${{ env.image }}:dev
ghcr.io/${{ env.image }}:dev
labels: |
${{ env.image }}:${{ github.sha }}
ghcr.io/${{ env.image }}:${{ github.sha }}
+25 -6
View File
@@ -6,7 +6,7 @@ on:
- v*
env:
image: ghcr.io/sbondco/watcharr
image: sbondco/watcharr
jobs:
release-images:
@@ -20,24 +20,43 @@ jobs:
- name: Checkout Repo
uses: actions/checkout@v4
- name: Log in to the Container registry
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: sbondco
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.image }}
images: |
${{ env.image }}
ghcr.io/${{ env.image }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
tags: |
${{ steps.meta.outputs.tags }}
ghcr.io/${{ steps.meta.outputs.tags }}
labels: |
${{ steps.meta.outputs.labels }}
ghcr.io/${{ steps.meta.outputs.labels }}
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
# Popular action to deploy to GitHub Pages:
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v3
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Build output to publish to the `gh-pages` branch:
+31
View File
@@ -0,0 +1,31 @@
name: Test PR (server)
on:
pull_request:
branches:
- "**"
tags-ignore:
- v*
paths:
- ".github/workflows/test-pr-server.yml"
- "server/**"
jobs:
format_check_go:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- name: gofmt test
run: |
outp=$(gofmt -l .)
if [ -n "$outp" ]; then
echo "Need to fix formatting!"
echo "$outp"
exit 1
else
echo "Everything looks formatted"
exit 0
fi
+31
View File
@@ -0,0 +1,31 @@
name: Test PR (web)
on:
pull_request:
branches:
- "**"
tags-ignore:
- v*
paths:
- ".github/workflows/test-pr-web.yml"
- "src/**"
jobs:
linter_check:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Get dependencies
run: npm i
- name: Check for linting/formatting errors
run: npm run lint
+5
View File
@@ -40,3 +40,8 @@ vite.config.ts.timestamp-*
# Docker dev volume
container_data/
data/
# Build artifacts
server/ui
server/watcharr
+1 -1
View File
@@ -12,4 +12,4 @@
"program": "${fileDirname}"
}
]
}
}
-1
View File
@@ -9,4 +9,3 @@ For big PRs (lots of changes/big change in the way something works), if you'd li
## Project Board
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
+4 -2
View File
@@ -1,14 +1,16 @@
# Backend
FROM golang:1.21-alpine AS server
FROM golang:1.22-alpine AS server
WORKDIR /server
COPY server/*.go server/go.* ./
COPY server/arr/*.go ./arr/
COPY server/game/*.go ./game/
# Required so we can build with cgo
RUN apk update && apk add --no-cache musl-dev gcc build-base
RUN go mod download && GOOS=linux CGO_ENABLED=1 go build -o ./watcharr
# CGO_CFLAGS: https://github.com/mattn/go-sqlite3/issues/1164#issuecomment-1635253695
RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./watcharr
# Frontend
FROM node:20-alpine AS ui
+25 -9
View File
@@ -7,9 +7,12 @@
<a href="https://watcharr.app"><img src="https://img.shields.io/website?label=DOCS&style=for-the-badge&url=https%3A%2F%2Fwatcharr.app" /></a>
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
<a href="https://matrix.to/#/#watcharr:matrix.org"><img src="https://img.shields.io/matrix/watcharr%3Amatrix.org?style=for-the-badge&logo=matrix" /></a>
</p>
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) movies and tv shows, rate them and track their status.
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) **movies** and **tv shows** (and **anime**), rate them and track their status.
With [some extra configuration](https://watcharr.app/docs/server_config/game-support-igdb) I can also track your **video games**.
I am built with Go and Svelte(Kit).
@@ -22,26 +25,27 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
- [Screenshots](#screenshots)
- [Setup](#set-up)
- [Contributing](CONTRIBUTING.md)
- [Community Made Tools](#community-made-tools)
- [Getting Help](#getting-help)
# Screenshots
<p align="center">
| Homepage | Watched Show Hover |
| -------- | ------------------ |
| Homepage | Watched Show Hover |
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
| <img src="./screenshot/homepage.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> |
| Watched Show Status Change | Movie Details |
| -------------------------- | ------------- |
| Watched Show Status Change | Movie Details |
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png" alt="Content Details Page" /> |
| User Profile | Discover |
| ------------ | -------- |
| User Profile | Discover |
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
| <img src="./screenshot/user-profile.png" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png" alt="Discover Page" /> |
| Dark Homepage | Dark Content Details |
| ------------- | -------------------- |
| Dark Homepage | Dark Content Details |
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
| <img src="./screenshot/homepage-dark.png" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png" alt="Dark Theme Content Page" /> |
</p>
@@ -50,6 +54,18 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [docker-compose.yml](./docker-compose.yml) file is your friend.
# Community Made Tools
Third-party tools made by the community for enhancing your Watcharr experience!
- [Kodi Plugin](https://github.com/airdogvan/watcharr_kodi) by [airdogvan](https://github.com/airdogvan) for automatically tracking your watched shows/movies.
Thanks to anyone that has made a script or tool for Watcharr. Feel free to add your own to the list if you have one!
**Note:** I cannot provide any assurances for these tools or stay on top of them (code review, etc), if you have any problems please open an issue in the project for the tool so that they can stay organized.
# Getting Help
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
+3 -1
View File
@@ -1,4 +1,6 @@
# Website
# Watcharr Docs
View the live site at [watcharr.app](https://watcharr.app/).
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
+1 -1
View File
@@ -1,3 +1,3 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
};
+8
View File
@@ -0,0 +1,8 @@
{
"label": "Importing",
"position": 3,
"link": {
"type": "generated-index",
"description": "Guides for importing your data from other services."
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+17
View File
@@ -0,0 +1,17 @@
---
sidebar_position: 1
---
# MyAnimeList
:::info Backup?
You may consider a backup of your server before starting any import. They are not easily reversible, though we do our best to ensure they are accurate and bug free!
:::
1. Export your list from MyAnimeList at: https://myanimelist.net/panel.php?go=export
![Hovering over poster](./img/mal/export.png)
2. Save the export anywhere and extract it to get your export `.xml` file.
3. Drag and drop your `.xml` file into Watcharr.
+2 -2
View File
@@ -1,5 +1,6 @@
---
sidebar_position: 1
description: Install and setup with Docker Compose.
---
# Docker Compose
@@ -9,8 +10,6 @@ sidebar_position: 1
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
```yaml title="docker-compose.yml"
version: "3"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
@@ -22,6 +21,7 @@ services:
volumes:
# Contains all of watcharr data (database & cache)
- ./data:/data
restart: unless-stopped
```
:::danger first account
+77
View File
@@ -0,0 +1,77 @@
---
sidebar_position: 11
description: Building from source for developing upon Watcharr.
---
# For Development
This guide will help you setup Watcharr for development in your local environment.
## Setup
This section assumes you will be forking the repo on Github, of course you can alter this section to work with your workflow, but if you intend to create a pull request with your work, it may be difficult if you're not also using Github (I don't know :()).
1. Fork the repository
On GitHub you can fork the repository with the 'Fork' button at the top.
![Fork button](./img/forking-repo.png)
2. Get the code by cloning your fork
```bash
git clone https://github.com/<your username>/Watcharr.git && \
cd Watcharr
```
## Setting Up Dependencies
You only need to do this once after pulling the codebase for the first time, and again every time the dependencies are changed/updated.
1. Install frontend dependencies (in the project root folder)
```
npm i
```
2. Install server dependencies (in the `server` folder)
```
go get .
```
## Running The Code
To run the code, you will need to open up two terminals, one for the frontend and the other for the backend.
1. Run the frontend (first terminal, in the project root folder)
```bash
npm run dev
```
2. Run the server (second terminal, in the project root folder)
```bash
npm run server
```
**Note:** If you're using Windows, running the server can be a little more complicated. You can follow this: https://github.com/sbondCo/Watcharr/discussions/430#discussioncomment-8894110 which amounts to these steps (the first 3 steps only need to be done once):
1. Set CGO_ENABLED environment variable with go
```
go env -w CGO_ENABLED=1
```
2. Install `gcc` (if you don't already have it installed) from https://jmeubank.github.io/tdm-gcc/ (found from this answer on stackoverflow: https://stackoverflow.com/a/43588236).
3. Install server dependencies again with `go get .` in the `server` folder.
4. Manually run the server, setting the MODE environment variable in powershell (from the `server` folder)
```
cmd /V /C "set MODE=DEV&& go run ."
```
3. Visit [http://localhost:5173/](http://localhost:5173/) and setup Watcharr.
+1
View File
@@ -1,5 +1,6 @@
---
sidebar_position: 10
description: Building and installing from source.
---
# From Source
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

+8
View File
@@ -0,0 +1,8 @@
{
"label": "Server Configuration",
"position": 2,
"link": {
"type": "generated-index",
"description": "Learn how to configure your server."
}
}
+41
View File
@@ -0,0 +1,41 @@
---
sidebar_position: 100
description: Backing up your server.
---
# Backing Up
Watcharr cannot handle backing up for you, but its simple structure should make it easy to slide it into your existing backup routine.
Once you have created your first backup, it's recommended that you try restoring from that backup to make sure everything is correct and will work in case you need to restore from it in the future.
:::danger Shutdown your server!
Always shutdown Watcharr before backing up its data, otherwise you run the risk of corrupting files (mainly your database).
:::
## Simple Steps
:::success Recommended
This is the recommended way to backup.
:::
Here are the steps that are run daily for the `beta.watcharr.app` instance.
1. Shutdown Watcharr.
2. Copy and backup the entire `data` folder (default location is `./data`, backup whichever folder your server is configured to store its data in).
3. Start Watcharr.
I call these the simple steps because they simply backup your entire server, if you ever need to restore your server, it will start up from its exact state at backup.
## Advanced Steps
:::danger Discouraged
Backing up this way is discouraged because new important files could be added later and missed.
:::
If you don't care about the warning not to backup this way, here are the "important" files that you can single out for backup:
- `watcharr.db` Your database, holds all users, their watchlists, etc.
- `watcharr.json` Server config.
- `img/up` Profile picture uploads.
- `img/games` Game posters (not exactly important, but scenarios in which this folder is not backed up have not been tested, only relevant for servers with game support enabled).
@@ -0,0 +1,14 @@
---
sidebar_position: 11
description: All environment variables you can use to start your server in a specific way.
---
# Environment Variables
Currently there is only one, and maybe it will stay that way forever (because the admin interface can handle most settings). A very lonely page right now ;(.
## `WATCHARR_DATA`
Make Watcharr use a custom directory for all of its data.
Defaults to `./data`.
@@ -0,0 +1,46 @@
---
sidebar_position: 1
description: Setting up video game support.
---
# Games Support (via IGDB/Twitch)
Game support in Watcharr, currently, must be enabled by you.
This involves setting up a Twitch developer account (phone number needed for 2FA which is required) and entering application credentials into Watcharr, that can then be used for looking up game info via IGDB.
P.S. These steps are stolen from [igdb api docs](https://api-docs.igdb.com/#account-creation), I've tried to make it easier to follow with screenshots.
## Setup Steps
### Setup On Twitch
1. Sign up for a [Twitch](https://dev.twitch.tv/login) account.
Click login with twitch, then create an account in the Sign Up tab.
2. Enable two factor authentication in your [account Security settings](https://www.twitch.tv/settings/security) (currently requires a phone number).
3. [Register a new Application in the Twitch Developer Console](https://dev.twitch.tv/console/apps/create).
These settings will do (name can be anything, we don't use OAuth, but a redirect url is required; localhost will do):
![Registering twitch application](./img/twitch-register-app.png)
4. [Click manage](https://dev.twitch.tv/console/apps) on your newly created application.
5. Click 'New Secret' to generate a new Client Secret.
6. Take a note of your Client ID and new Client Secret (by leaving this tab open, until we are ready to copy the values into Watcharr).
### Setup On Watcharr
1. Login to Watcharr with an Admin account and navigate to server settings.
![Navigating to server settings in Watcharr](./img/watcharr-settings-dropdown.png)
2. Scroll down and click on the Twitch service.
![Twitch service config button](./img/watcharr-twitch-service.png)
3. Enter your Client ID and Client Secret (from Twitch Application page, step 6 on twitch setup steps)
![Twitch service config](./img/watcharr-twitch-config.png)
4. Click save, and start searching for your favourite games!
![Watcharr game details page](./img/watcharr-game-details.png)
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

+28
View File
@@ -0,0 +1,28 @@
---
sidebar_position: 9
description: Enabling Jellyfin as an authentication provider.
---
# Jellyfin Authentication
Watcharr can use your Jellyfin server as an authentication provider. There is just one simple step to set this up.
:::danger Take care
It is not recommended that this value is changed once it has been set and users have signed up.
Changing this setting can cause users to be unable to login to the correct account.
:::
## Setup Steps
1. Login to Watcharr with an Admin account and navigate to server settings.
![Navigating to server settings in Watcharr](./img/watcharr-settings-dropdown.png)
2. Scroll down to find the Jellyfin Host setting and enter the URL to your Jellyfin server.
![Entering Jellyfin server URL into input](./img/watcharr-jellyfin-host-setting.png)
3. Jellyfin authentication has now been enabled! Go back to the login screen and you will be able to login via Jellyfin.
+24
View File
@@ -0,0 +1,24 @@
---
sidebar_position: 10
description: Enabling Plex as an authentication provider.
---
# Plex Authentication
Watcharr can use Plex as an authentication provider. There is just one simple step to set this up.
## Setup Steps
1. Login to Watcharr with an Admin account and navigate to server settings.
![Navigating to server settings in Watcharr](./img/watcharr-settings-dropdown.png)
2. Scroll down and find the Plex Host setting.
![Plex host setting input](./img/watcharr-plex-host-setting.png)
3. Enter the URL to your home Plex server.
You will see your Plex machine identifier popup below the input after it's saved, if it worked.
Only users with access to your library will be able to signup via Plex.
![Entering home Plex server URL into input](./img/watcharr-plex-host-setting-filled.png)
+28
View File
@@ -0,0 +1,28 @@
---
sidebar_position: 10
description: Getting admin privileges for your account without already having access to an account with admin.
---
# Restoring Admin Priviliges
This guide will help you restore/give admin access to any account without needing an account with admin. If you ever accidentally remove your permissions, this is the easiest way to get them back.
All you will need is access to the `watcharr.log` file.
## Steps
1. Login with the account you'd like to give admin to.
2. Navigate to the `/request_admin` page manually (ex: `127.0.0.1:3080/request_admin`) and click the `request` button.
![requesting an admin token](./img/request-admin/request-admin-btn.png)
3. Check your `watcharr.log` file (or simply check `docker compose logs` if using docker compose) and look for the generated admin token (highlighted in the image below).
![finding admin token in log](./img/request-admin/admin-token-generated-log-example.png)
4. Enter the code from the log (you should have been taken here after clicking the request button in step 2).
![redeeming admin token](./img/request-admin/redeeming-admin-token.png)
5. Click `Check Token`, if it is correct and still valid (they expire after ~2 minutes or once used), your account will now have admin.
+14 -5
View File
@@ -1,8 +1,9 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
const { themes } = require("prism-react-renderer");
const lightTheme = themes.github;
const darkTheme = themes.dracula;
/** @type {import('@docusaurus/types').Config} */
const config = {
@@ -53,7 +54,10 @@ const config = {
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
},
theme: {
customCss: require.resolve("./src/css/custom.css")
customCss: [
"./node_modules/viewerjs/dist/viewer.css",
require.resolve("./src/css/custom.css")
]
}
})
]
@@ -79,6 +83,11 @@ const config = {
{
type: "docsVersionDropdown"
},
{
href: "https://beta.watcharr.app",
label: "Demo",
position: "right"
},
{
href: "https://github.com/sbondCo/Watcharr",
label: "GitHub",
@@ -87,8 +96,8 @@ const config = {
]
},
prism: {
theme: lightCodeTheme
// darkTheme: darkCodeTheme
theme: lightTheme,
darkTheme: darkTheme
},
colorMode: {
defaultMode: "light",
+4822 -2949
View File
File diff suppressed because it is too large Load Diff
+10 -9
View File
@@ -14,16 +14,17 @@
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "2.4.3",
"@docusaurus/preset-classic": "2.4.3",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"@docusaurus/core": "3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"viewerjs": "^1.11.6"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.4.3"
"@docusaurus/module-type-aliases": "3.1.1"
},
"browserslist": {
"production": [
@@ -38,6 +39,6 @@
]
},
"engines": {
"node": ">=16.14"
"node": ">=18.0"
}
}
+1 -1
View File
@@ -14,7 +14,7 @@
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
// But you can create a sidebar manually
/*
+71
View File
@@ -0,0 +1,71 @@
import React, { useEffect } from "react";
import styles from "./styles.module.css";
import clsx from "clsx";
import Viewer from "viewerjs";
function TwoColFeat({ title, description, imgSrc }) {
return (
<div className={clsx(styles.twoCol)}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div>
<img src={imgSrc} alt="" loading="lazy" />
</div>
</div>
);
}
export function HomepageTwoColFeats() {
useEffect(() => {
const ctr = document.getElementById("homepage-two-col-feats");
console.debug("Starting image viewer", ctr);
new Viewer(ctr, {
navbar: 0,
toolbar: {
zoomIn: 2,
zoomOut: 2,
next: 2,
prev: 2
},
title: 0,
className: "twoColViewer"
});
}, []);
return (
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
<TwoColFeat
title="Neat UI"
description="We just look lightweight."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
/>
<TwoColFeat
title="Easy To Use"
description="Not much to it, other than ease of use."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
/>
<TwoColFeat
title="In-Depth Details"
description="About your favourite content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
/>
<TwoColFeat
title="Personal Stats and Configuration"
description="Make it how you like."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
/>
<TwoColFeat
title="Discovery"
description="Find the latest trending content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
/>
<TwoColFeat
title="Dark Theme"
description="For you who are still unsatisfied. ;("
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
/>
</section>
);
}
@@ -0,0 +1,53 @@
.twoColFeats {
display: flex;
align-items: center;
margin-left: auto;
margin-right: auto;
flex-flow: column;
width: 100%;
max-width: 1300px;
}
.twoCol {
display: flex;
flex-flow: row;
width: 100%;
padding: 2.5rem 5rem;
gap: 50px;
background-color: rgb(236, 236, 236);
}
.twoCol:nth-child(even) {
flex-flow: row-reverse;
background-color: transparent;
}
.twoCol > div {
width: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
}
.twoCol > div > img {
width: 100%;
border-radius: 12px;
box-shadow: 0px 0px 5px rgb(121, 121, 121);
cursor: pointer;
}
@media screen and (max-width: 850px) {
.twoCol,
.twoCol:nth-child(even) {
flex-flow: column;
}
.twoCol > div {
width: 100%;
}
.twoCol > div > img {
max-width: 500px;
}
}
+15 -1
View File
@@ -18,7 +18,7 @@
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
[data-theme="dark"] {
--ifm-color-primary: #25c2a0;
--ifm-color-primary-dark: #21af90;
--ifm-color-primary-darker: #1fa588;
@@ -28,3 +28,17 @@
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
.twoColViewer .viewer-close {
right: 10px;
top: 10px;
width: 50px;
height: 50px;
}
/* Stupid poopin fix for viewerjs because whatever is bundling the css
isn't keeping the order of this correctly. All this time wasted and it
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
.viewer-in {
opacity: 1 !important;
}
+2
View File
@@ -7,6 +7,7 @@ import LogoCol from "@site/static/img/logo-col.png";
import styles from "./index.module.css";
import clsx from "clsx";
import { HomepageTwoColFeats } from "../components/TwoColFeat";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
@@ -45,6 +46,7 @@ export default function Home() {
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageTwoColFeats />
</main>
</Layout>
);
-2
View File
@@ -1,5 +1,3 @@
version: "3"
# To test prod
services:
watcharr:
+5 -3
View File
@@ -1,10 +1,12 @@
version: "3"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
# to use an actual version, then when updating check the releases for changelogs.
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
ports:
- 3080:3080
volumes:
- ./container_data:/data
# Contains all of watcharr data (database & cache)
- ./data:/data
restart: unless-stopped
+5696 -606
View File
File diff suppressed because it is too large Load Diff
+21 -19
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "1.25.0",
"version": "1.43.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -13,27 +13,29 @@
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.27.6",
"@types/papaparse": "^5.3.11",
"@typescript-eslint/eslint-plugin": "^6.11.0",
"@typescript-eslint/parser": "^6.11.0",
"eslint": "^8.54.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-svelte": "^2.35.0",
"prettier": "^3.0.3",
"prettier-plugin-svelte": "^3.0.3",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"svelte-eslint-parser": "^0.33.1",
"svelte-preprocess": "^5.1.0",
"tslib": "^2.6.2",
"typescript": "^5.3.2",
"vite": "^4.5.0"
"@sveltejs/adapter-node": "^5.2.3",
"@sveltejs/kit": "^2.5.27",
"@types/papaparse": "^5.3.14",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.8.0",
"@vite-pwa/sveltekit": "^0.5.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.43.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.6",
"svelte": "^4.2.19",
"svelte-check": "^4.0.2",
"svelte-eslint-parser": "^0.41.0",
"svelte-preprocess": "^6.0.2",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"vite": "^5.4.6"
},
"type": "module",
"dependencies": {
"axios": "^1.6.1",
"axios": "^1.7.4",
"blurhash": "^2.0.5",
"papaparse": "^5.4.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 523 KiB

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 371 KiB

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 KiB

After

Width:  |  Height:  |  Size: 661 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 948 KiB

After

Width:  |  Height:  |  Size: 820 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 866 KiB

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 877 KiB

After

Width:  |  Height:  |  Size: 752 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 945 KiB

After

Width:  |  Height:  |  Size: 816 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+69 -12
View File
@@ -10,19 +10,36 @@ import (
type ActivityType string
// _AUTO activities are for when logic updates something for the user (automations basically).
var (
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
RATING_CHANGED ActivityType = "RATING_CHANGED"
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
SEASON_ADDED ActivityType = "SEASON_ADDED"
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
RATING_CHANGED ActivityType = "RATING_CHANGED"
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
SEASON_ADDED ActivityType = "SEASON_ADDED"
SEASON_ADDED_AUTO ActivityType = "SEASON_ADDED_AUTO"
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
SEASON_STATUS_CHANGED_AUTO ActivityType = "SEASON_STATUS_CHANGED_AUTO"
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
)
type Activity struct {
@@ -48,6 +65,10 @@ type ActivityAddRequest struct {
CustomDate *time.Time `json:"customDate,omitempty"`
}
type ActivityUpdateRequest struct {
CustomDate time.Time `json:"customDate" binding:"required"`
}
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
activity := new([]Activity)
res := db.Model(&Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
@@ -71,3 +92,39 @@ func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, err
slog.Debug("Adding activity", "added_activity", activity)
return activity, nil
}
func updateActivity(db *gorm.DB, userId uint, id uint, activityUpdateRequest ActivityUpdateRequest) error {
if id == 0 {
return errors.New("id must be set to update an activity")
}
if activityUpdateRequest.CustomDate.IsZero() {
return errors.New("customDate must be set to update an activity")
}
res := db.Model(&Activity{}).Where("user_id = ? AND id = ?", userId, id).Update("custom_date", activityUpdateRequest.CustomDate)
if res.Error != nil {
slog.Error("Error updating activity in database", "error", res.Error.Error())
return errors.New("failed updating activity in database")
}
if res.RowsAffected < 1 {
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
return errors.New("failed updating activity in database")
}
slog.Debug("Updating activity", "updated_activity", id)
return nil
}
func deleteActivity(db *gorm.DB, userId uint, id uint) error {
if id == 0 {
return errors.New("an id must be provided to delete an activity")
}
res := db.Where("user_id = ?", userId).Delete(&Activity{}, id)
if res.Error != nil {
slog.Error("Error deleting activity in database", "error", res.Error.Error())
return errors.New("failed deleting activity in database")
}
if res.RowsAffected < 1 {
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
return errors.New("failed deleting activity from database")
}
return nil
}
+2
View File
@@ -56,6 +56,7 @@ type RadarrTestResponse struct {
LanguageProfiles []arr.LanguageProfile `json:"languageProfiles"`
}
// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
sonarr := arr.New(arr.SONARR, &p.Host, &p.Key)
qps, err := sonarr.GetQualityProfiles()
@@ -76,6 +77,7 @@ func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
return SonarrTestResponse{QualityProfiles: qps, RootFolders: rfs, LanguageProfiles: lps}, nil
}
// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
func testRadarr(p ArrTestParams) (RadarrTestResponse, error) {
radarr := arr.New(arr.RADARR, &p.Host, &p.Key)
qps, err := radarr.GetQualityProfiles()
+88 -20
View File
@@ -10,6 +10,7 @@ import (
"log/slog"
"net/http"
"net/url"
"strconv"
)
type ArrType string
@@ -37,6 +38,7 @@ type ArrRequest struct {
AutomaticSearch bool `json:"automaticSearch"`
Title string `json:"title"` // content name
Year int `json:"year"` // content year
TMDBID int `json:"tmdbId"`
}
type SonarrRequest struct {
@@ -49,7 +51,6 @@ type SonarrRequest struct {
type RadarrRequest struct {
ArrRequest
TMDBID int `json:"tmdbId"`
}
type SonarrSeasons struct {
@@ -66,9 +67,9 @@ func New(t ArrType, host *string, key *string) *Arr {
}
func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
slog.Info("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
slog.Debug("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
var resp []QualityProfile
err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
_, err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
if err != nil {
slog.Error("GetQualityProfiles request failed", "service", a.Type, "error", err)
return []QualityProfile{}, errors.New("request to service failed")
@@ -77,9 +78,9 @@ func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
}
func (a *Arr) GetRootFolders() ([]RootFolder, error) {
slog.Info("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
slog.Debug("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
var resp []RootFolder
err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
_, err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
if err != nil {
slog.Error("GetRootFolders request failed", "service", a.Type, "error", err)
return []RootFolder{}, errors.New("request to service failed")
@@ -88,10 +89,11 @@ func (a *Arr) GetRootFolders() ([]RootFolder, error) {
}
func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
slog.Info("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
slog.Debug("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
var resp []LanguageProfile
// languageprofile supposedly deprecated.. but new language endpoint doesnt seem to work.. note probs to switch soon
err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
// TODO languages are handled diffferently in Sonarr now, I think we can remove all language stuff, now controlled per profile.
_, err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
if err != nil {
slog.Error("GetLangaugeProfiles request failed", "service", a.Type, "error", err)
return []LanguageProfile{}, errors.New("request to service failed")
@@ -99,7 +101,69 @@ func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
return resp, nil
}
// TODO do title! and year?
func (a *Arr) RunCommand(name string) (CommandResponse, error) {
slog.Debug("RunCommand", "name", name, "type", a.Type, "host", *a.Host, "key", *a.Key)
var resp CommandResponse
err := requestPost(*a.Host, "/command", *a.Key, map[string]interface{}{"name": name}, &resp)
if err != nil {
slog.Error("RunCommand request failed", "name", name, "service", a.Type, "error", err)
return CommandResponse{}, errors.New("request to service failed")
}
return resp, nil
}
// arrId = movieId/seriesId on radarr/sonarr
func (a *Arr) GetQueueDetails(arrId string, resp interface{}) error {
slog.Debug("GetQueueDetails", "arrId", arrId, "type", a.Type, "host", *a.Host, "key", *a.Key)
p := map[string]string{"apikey": *a.Key}
if a.Type == RADARR {
p["movieId"] = arrId
} else if a.Type == SONARR {
p["seriesId"] = arrId
} else {
return errors.New("invalid arr type")
}
_, err := request(*a.Host, "/queue/details", p, resp)
if err != nil {
slog.Error("GetQueueDetails request failed", "arrId", arrId, "service", a.Type, "error", err)
return errors.New("request to service failed")
}
return nil
}
// Get movie/show
func (a *Arr) GetContent(arrId int) (MovieSerie, int, error) {
slog.Debug("GetContent", "arrId", arrId, "type", a.Type, "host", *a.Host, "key", *a.Key)
e := "movie"
if a.Type == SONARR {
e = "series"
}
arrIdStr := strconv.Itoa(arrId)
var resp MovieSerie
respStatusCode, err := request(*a.Host, "/"+e+"/"+arrIdStr, map[string]string{"apikey": *a.Key}, &resp)
if err != nil {
slog.Error("GetContent request failed", "arrId", arrId, "service", a.Type, "error", err)
return MovieSerie{}, respStatusCode, errors.New("request to service failed")
}
return resp, respStatusCode, nil
}
func (a *Arr) LookupByTmdbId(tmdbId int) ([]MovieSerie, error) {
slog.Debug("LookupByTmdbId", "tmdbId", tmdbId, "type", a.Type, "host", *a.Host, "key", *a.Key)
e := "movie"
if a.Type == SONARR {
e = "series"
}
var resp []MovieSerie
tmdbIdStr := strconv.Itoa(tmdbId)
_, err := request(*a.Host, "/"+e+"/lookup", map[string]string{"apikey": *a.Key, "term": "tmdb:" + tmdbIdStr}, &resp)
if err != nil {
slog.Error("LookupByTmdbId request failed", "tmdbId", tmdbId, "service", a.Type, "error", err)
return []MovieSerie{}, errors.New("request to service failed")
}
return resp, nil
}
func (a *Arr) BuildAddShowBody(r SonarrRequest) map[string]interface{} {
req := map[string]interface{}{
"title": r.Title,
@@ -135,26 +199,27 @@ func (a *Arr) BuildAddMovieBody(r RadarrRequest) map[string]interface{} {
return req
}
func (a *Arr) AddContent(b map[string]interface{}) error {
func (a *Arr) AddContent(b map[string]interface{}) (map[string]interface{}, error) {
ep := "series"
if a.Type == RADARR {
ep = "movie"
}
slog.Debug("AddContent", "type", ep, "body", b)
var resp interface{}
var resp map[string]interface{}
err := requestPost(*a.Host, "/"+ep, *a.Key, b, &resp)
if err != nil {
slog.Error("AddContent request failed", "service", a.Type, "error", err)
return errors.New("request to service failed")
return map[string]interface{}{}, errors.New("request to service failed")
}
return nil
slog.Debug("AddContent", "type", ep, "created_id", resp["id"])
return resp, nil
}
func request(host string, ep string, p map[string]string, resp interface{}) error {
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
func request(host string, ep string, p map[string]string, resp interface{}) (int, error) {
slog.Debug("arrAPIRequest", "endpoint", ep, "params", p)
base, err := url.Parse(host)
if err != nil {
return errors.New("failed to parse api uri")
return 0, errors.New("failed to parse api uri")
}
// Path params
@@ -172,23 +237,26 @@ func request(host string, ep string, p map[string]string, resp interface{}) erro
// Run get request
res, err := http.Get(base.String())
if err != nil {
return err
if res != nil {
return res.StatusCode, err
}
return 0, err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return err
return res.StatusCode, err
}
if res.StatusCode != 200 {
slog.Error("arr non 200 status code:", "status_code", res.StatusCode)
return errors.New(string(body))
return res.StatusCode, errors.New(string(body))
}
// slog.Info("", "body", body)
err = json.Unmarshal([]byte(body), &resp)
if err != nil {
return err
return res.StatusCode, err
}
return nil
return res.StatusCode, nil
}
func requestPost(host string, ep string, key string, p map[string]interface{}, resp interface{}) error {
+98
View File
@@ -1,5 +1,7 @@
package arr
import "time"
type QualityProfile struct {
Name string `json:"name"`
UpgradeAllowed bool `json:"upgradeAllowed"`
@@ -43,3 +45,99 @@ type LanguageProfile struct {
} `json:"languages"`
ID int `json:"id"`
}
type ArrCommandRequest struct {
Name string `json:"name"`
}
type CommandResponse struct {
ID int `json:"id"`
Name string `json:"name"`
CommandName string `json:"commandName"`
Message string `json:"message"`
Body struct {
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
LastExecutionTime time.Time `json:"lastExecutionTime"`
LastStartTime time.Time `json:"lastStartTime"`
Trigger string `json:"trigger"`
SuppressMessages bool `json:"suppressMessages"`
ClientUserAgent string `json:"clientUserAgent"`
} `json:"body"`
Priority string `json:"priority"`
Status string `json:"status"`
Result string `json:"result"`
Queued time.Time `json:"queued"`
Started time.Time `json:"started"`
Ended time.Time `json:"ended"`
Duration struct {
Ticks int `json:"ticks"`
} `json:"duration"`
Exception string `json:"exception"`
Trigger string `json:"trigger"`
ClientUserAgent string `json:"clientUserAgent"`
StateChangeTime time.Time `json:"stateChangeTime"`
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
UpdateScheduledTask bool `json:"updateScheduledTask"`
LastExecutionTime time.Time `json:"lastExecutionTime"`
}
type QueueDetail struct {
Languages []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"languages"`
Quality struct {
Quality struct {
ID int `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Resolution int `json:"resolution"`
Modifier string `json:"modifier"`
} `json:"quality"`
Revision struct {
Version int `json:"version"`
Real int `json:"real"`
IsRepack bool `json:"isRepack"`
} `json:"revision"`
} `json:"quality"`
Size float64 `json:"size"`
Title string `json:"title"`
SizeLeft float64 `json:"sizeleft"`
TimeLeft string `json:"timeleft"`
EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"`
Added time.Time `json:"added"`
Status string `json:"status"`
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
TrackedDownloadState string `json:"trackedDownloadState"`
StatusMessages []interface{} `json:"statusMessages"`
DownloadID string `json:"downloadId"`
Protocol string `json:"protocol"`
DownloadClient string `json:"downloadClient"`
DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"`
Indexer string `json:"indexer"`
ID int `json:"id"`
}
type RadarrQueueDetails []struct {
QueueDetail
MovieID int `json:"movieId"`
}
type SonarrQueueDetails []struct {
QueueDetail
SeriesID int `json:"seriesId"`
EpisodeID int `json:"episodeId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeHasFile bool `json:"episodeHasFile"`
}
// From `GET /movie/{id}` or `GET /series/{id}`.
// Not all fields described here, just the wanted ones.
type MovieSerie struct {
Title string `json:"title"`
OriginalTitle string `json:"originalTitle"`
ID int `json:"id"`
HasFile bool `json:"hasFile"`
IsAvailable bool `json:"isAvailable"`
Added time.Time `json:"added"`
}
+133
View File
@@ -0,0 +1,133 @@
package main
import (
"errors"
"log/slog"
"math"
"time"
"github.com/sbondCo/Watcharr/arr"
)
type ArrDetailsResponse struct {
Progress int `json:"progress"`
EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"`
Status string `json:"status"`
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
TrackedDownloadState string `json:"trackedDownloadState"`
}
type SonarrDetailsResponseItem struct {
ArrDetailsResponse
Title string `json:"title"`
}
type SonarrDetailsResponse struct {
// Overall progress
Progress int `json:"progress"`
// Overall status
Status string `json:"status"`
// Each downloading episode
Items []SonarrDetailsResponseItem `json:"items"`
}
func getRadarrQueueDetails(serverName string, arrId string) (*ArrDetailsResponse, error) {
server, err := getRadarr(serverName)
if err != nil {
slog.Error("getRadarrQueueDetails: Failed to get server", "error", err)
return &ArrDetailsResponse{}, errors.New("failed to get server")
}
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
// Run refresh downloads, likely won't be refreshed in time before we run GetQueueDetails below,
// but if the user calls this again, it should be, which is better than waiting a whole minute for
// refresh task to run automatically. This exists until a better solution is thought of.
_, err = radarr.RunCommand("RefreshMonitoredDownloads")
if err != nil {
slog.Error("getRadarrQueueDetails: Failed to refresh monitored downloads.", "error", err)
}
resp := arr.RadarrQueueDetails{}
err = radarr.GetQueueDetails(arrId, &resp)
if err != nil {
slog.Error("getRadarrQueueDetails: Failed to add content", "error", err)
return &ArrDetailsResponse{}, errors.New("failed to add content")
}
if len(resp) <= 0 {
slog.Error("getRadarrQueueDetails: No results in response")
return &ArrDetailsResponse{}, errors.New("no details found")
}
adr := ArrDetailsResponse{}
adr.Progress = int(math.Round((1 - (resp[0].SizeLeft / resp[0].Size)) * 100))
adr.EstimatedCompletionTime = resp[0].EstimatedCompletionTime
adr.TrackedDownloadStatus = resp[0].TrackedDownloadStatus
adr.TrackedDownloadState = resp[0].TrackedDownloadState
adr.Status = resp[0].Status
return &adr, nil
}
func getSonarrQueueDetails(serverName string, arrId string) (*SonarrDetailsResponse, error) {
server, err := getSonarr(serverName)
if err != nil {
slog.Error("getSonarrQueueDetails: Failed to get server", "error", err)
return &SonarrDetailsResponse{}, errors.New("failed to get server")
}
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
// Run refresh downloads, likely won't be refreshed in time before we run GetQueueDetails below,
// but if the user calls this again, it should be, which is better than waiting a whole minute for
// refresh task to run automatically. This exists until a better solution is thought of.
_, err = sonarr.RunCommand("RefreshMonitoredDownloads")
if err != nil {
slog.Error("getSonarrQueueDetails: Failed to refresh monitored downloads.", "error", err)
}
resp := arr.SonarrQueueDetails{}
err = sonarr.GetQueueDetails(arrId, &resp)
if err != nil {
slog.Error("getSonarrQueueDetails: Failed to add content", "error", err)
return &SonarrDetailsResponse{}, errors.New("failed to add content")
}
if len(resp) <= 0 {
slog.Error("getSonarrQueueDetails: No results in response")
return &SonarrDetailsResponse{}, errors.New("no details found")
}
dr := SonarrDetailsResponse{}
totalSize := 0.0
totalSizeLeft := 0.0
for _, v := range resp {
dr.Items = append(dr.Items, SonarrDetailsResponseItem{
Title: "TODO :(", // TODO: Will need to do another request to get all episodes so we can get real episode names from EpisodeID
ArrDetailsResponse: ArrDetailsResponse{
Progress: int(math.Round((1 - (v.SizeLeft / v.Size)) * 100)),
EstimatedCompletionTime: v.EstimatedCompletionTime,
TrackedDownloadStatus: v.TrackedDownloadStatus,
TrackedDownloadState: v.TrackedDownloadState,
Status: v.Status,
},
})
totalSize += v.Size
totalSizeLeft += v.SizeLeft
}
dr.Progress = int(math.Round((1 - (totalSizeLeft / totalSize)) * 100))
// HACK: Statuses are not great, no easy way to get a good status, since there are multiple items to consider.
// At some point, we could add extra checks (ex. if all items (episodes) are paused, overall status can be set to paused).
// For now, this is good enough.
dr.Status = "requested"
if dr.Progress > 0 {
dr.Status = "downloading"
}
return &dr, nil
}
// Refresh download queues for our sonarr/radarr servers.
// If the queues don't refresh regularly, our queue detail
// calls will just always return the same info.
func refreshArrQueues() {
slog.Debug("refreshArrQueues: Refreshing queues for all configured arr servers.")
// We don't care about responses, errors will be logged by the RunCommand func.
for _, v := range Config.RADARR {
radarr := arr.New(arr.RADARR, &v.Host, &v.Key)
radarr.RunCommand("RefreshMonitoredDownloads")
}
for _, v := range Config.SONARR {
sonarr := arr.New(arr.SONARR, &v.Host, &v.Key)
sonarr.RunCommand("RefreshMonitoredDownloads")
}
}
+294
View File
@@ -0,0 +1,294 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"time"
"github.com/sbondCo/Watcharr/arr"
"gorm.io/gorm"
)
type ArrRequestStatus string
const (
// Pending approval from an admin.
ARR_REQUEST_PENDING ArrRequestStatus = "PENDING"
// Request has been approved and should be added to sonarr/radarr.
ARR_REQUEST_APPROVED ArrRequestStatus = "APPROVED"
ARR_REQUEST_AUTO_APPROVED ArrRequestStatus = "AUTO_APPROVED"
// Request has been denied, not adding content.
ARR_REQUEST_DENIED ArrRequestStatus = "DENIED"
// Content was found on sonarr/radarr already, nothing needs to be done.
ARR_REQUEST_FOUND ArrRequestStatus = "FOUND"
)
type ArrRequest struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UserID uint `json:"-" gorm:"not null"`
User User `json:"-"`
// Username of `User`.
// We don't want to send back the entire user object, just their name.
// Not stored in DB, only used for our response from api.
Username string `json:"username" gorm:"-"`
ContentID *int `json:"-" gorm:"uniqueIndex:sn_to_cid;not null"`
Content *Content `json:"content,omitempty"`
// Server names are used as an identifier
ServerName string `json:"serverName" gorm:"uniqueIndex:sn_to_cid;not null"`
// Sonarr/Radarrs seriesId/movieId
ArrID int `json:"arrId"`
// Tracked request status
Status ArrRequestStatus `json:"status" gorm:"default:PENDING"`
// Full request made by user (arr.SonarrRequest / arr.RadarrRequest)
// so we know how to fulfil the request if approved.
RequestJson string `json:"requestJson"`
}
func deleteArrRequest(db *gorm.DB, id uint) error {
resp := db.Delete(&ArrRequest{ID: id})
if resp.Error != nil {
slog.Error("deleteArrRequest: Failed to remove from db", "error", resp.Error)
return errors.New("failed when removing request")
}
return nil
}
// Gets all requests.
func getArrRequests(db *gorm.DB) ([]ArrRequest, error) {
var req []ArrRequest
resp := db.Preload("Content").Preload("User").Find(&req)
if resp.Error != nil {
slog.Error("getArrRequests: Failed to search for requests in db", "error", resp.Error)
return []ArrRequest{}, errors.New("failed to find requests")
}
for i := range req {
req[i].Username = req[i].User.Username
}
return req, nil
}
func getArrRequest(db *gorm.DB, requestId uint) (ArrRequest, error) {
var req ArrRequest
resp := db.Where("id = ?", requestId).Take(&req)
if resp.Error != nil {
slog.Error("getArrRequest: Failed to search for request in db", "error", resp.Error)
return ArrRequest{}, errors.New("failed to find request")
}
return req, nil
}
func getArrRequestByTmdbId(db *gorm.DB, contentType ContentType, tmdbId int) (ArrRequest, error) {
var req ArrRequest
resp := db.Joins("JOIN contents ON contents.id = arr_requests.content_id AND contents.tmdb_id = ? AND contents.type = ?", tmdbId, contentType).Find(&req)
if resp.Error != nil {
slog.Error("getArrRequestByTmdbId: Failed to search for request in db", "error", resp.Error)
return ArrRequest{}, errors.New("failed to find request")
}
return req, nil
}
func createArrRequest(db *gorm.DB, userId uint, serverName string, contentType ContentType, tmdbId int, reqJson string) (*ArrRequest, error) {
content, err := getOrCacheContent(db, contentType, tmdbId)
if err != nil {
slog.Error("createArrRequest: getOrCacheContent errored.")
return &ArrRequest{}, err
}
req := ArrRequest{UserID: userId, ServerName: serverName, ContentID: &content.ID, RequestJson: reqJson}
resp := db.Create(&req)
if resp.Error != nil {
slog.Error("createArrRequest: Failed when inserting request into db.", "error", err)
return &ArrRequest{}, errors.New("failed when adding request")
}
return &req, nil
}
func createSonarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.SonarrRequest) (*ArrRequest, error) {
server, err := getSonarr(ur.ServerName)
if err != nil {
slog.Error("createSonarrRequest: Failed to get server", "error", err)
return &ArrRequest{}, errors.New("failed to get server")
}
reqJson, err := json.Marshal(ur)
if err != nil {
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
return &ArrRequest{}, errors.New("failed when processing request")
}
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
arrReq, err := createArrRequest(db, userId, ur.ServerName, SHOW, ur.TMDBID, string(reqJson[:]))
if err != nil {
slog.Error("createSonarrRequest: Failed when creating arr request", "error", err)
return &ArrRequest{}, errors.New("failed when creating request")
}
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
// 1. Lookup on Sonarr to check if the show has already been added (via method other than watcharr).
lookupRes, err := sonarr.LookupByTmdbId(ur.TMDBID)
if err == nil && len(lookupRes) == 1 {
slog.Debug("createSonarrRequest: Lookup returned results.")
found := lookupRes[0] // There should only be one result when looking up by id.
// If it has an ID, then it will have already been added to Sonarr.
if found.ID != 0 {
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
if dbResp.Error != nil {
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
} else {
slog.Debug("createSonarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
arrReq.ArrID = found.ID
return arrReq, nil
}
}
}
// 2. If user has auto approve perms, add movie to sonarr.
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
slog.Debug("createSonarrRequest: User has auto approve permission.. sending request to Sonarr.")
ur.AutomaticSearch = server.AutomaticSearch
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
if err != nil {
slog.Error("createSonarrRequest: Failed to add content", "error", err)
return &ArrRequest{}, errors.New("failed to add content")
}
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
if dbResp.Error != nil {
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
}
arrId, ok := resp["id"].(float64)
if !ok {
slog.Error("createSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
return &ArrRequest{}, errors.New("failed to get arr id")
}
arrReq.ArrID = int(arrId)
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
}
return arrReq, nil
}
func createRadarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.RadarrRequest) (*ArrRequest, error) {
server, err := getRadarr(ur.ServerName)
if err != nil {
slog.Error("createRadarrRequest: Failed to get server", "error", err)
return &ArrRequest{}, errors.New("failed to get server")
}
reqJson, err := json.Marshal(ur)
if err != nil {
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
return &ArrRequest{}, errors.New("failed when processing request")
}
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
arrReq, err := createArrRequest(db, userId, ur.ServerName, MOVIE, ur.TMDBID, string(reqJson[:]))
if err != nil {
slog.Error("createRadarrRequest: Failed when creating arr request", "error", err)
return &ArrRequest{}, errors.New("failed when creating request")
}
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
// 1. Lookup on Radarr to check if the movie has already been added (via method other than watcharr).
lookupRes, err := radarr.LookupByTmdbId(ur.TMDBID)
if err == nil && len(lookupRes) == 1 {
slog.Debug("createRadarrRequest: Lookup returned results.")
found := lookupRes[0] // There should only be one result when looking up by id.
// If it has an ID, then it will have already been added to Radarr.
if found.ID != 0 {
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
if dbResp.Error != nil {
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
} else {
slog.Debug("createRadarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
arrReq.ArrID = found.ID
return arrReq, nil
}
}
}
// 2. If user has auto approve perms, add movie to radarr.
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
slog.Debug("createRadarrRequest: User has auto approve permission.. sending request to Radarr.")
ur.AutomaticSearch = server.AutomaticSearch
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
if err != nil {
slog.Error("createRadarrRequest: Failed to add content", "error", err)
return &ArrRequest{}, errors.New("failed to add content")
}
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
if dbResp.Error != nil {
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
}
arrId, ok := resp["id"].(float64)
if !ok {
slog.Error("createRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
return &ArrRequest{}, errors.New("failed to get arr id")
}
arrReq.ArrID = int(arrId)
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
}
return arrReq, nil
}
func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
if requestId == 0 {
slog.Error("sonarr info: No request id provided")
return arr.MovieSerie{}, errors.New("no request id provided")
}
arrRequest, err := getArrRequest(db, requestId)
if err != nil {
slog.Error("radarr info: Failed to get server", "error", err)
return arr.MovieSerie{}, errors.New("failed to get server")
}
server, err := getRadarr(arrRequest.ServerName)
if err != nil {
slog.Error("radarr info: Failed to get server", "error", err)
return arr.MovieSerie{}, errors.New("failed to get server")
}
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
resp, respStatusCode, err := radarr.GetContent(arrRequest.ArrID)
if err != nil {
slog.Error("radarr info: Failed to get info", "error", err)
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
slog.Error("radarr info: 404 returned.. content must've been removed.. removing request.")
err := deleteArrRequest(db, arrRequest.ID)
if err != nil {
return arr.MovieSerie{}, errors.New("failed to delete request for removed content")
} else {
return arr.MovieSerie{}, errors.New("request deleted")
}
}
return arr.MovieSerie{}, errors.New("failed to get info")
}
return resp, nil
}
func getSonarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
if requestId == 0 {
slog.Error("sonarr info: No request id provided")
return arr.MovieSerie{}, errors.New("no request id provided")
}
arrRequest, err := getArrRequest(db, requestId)
if err != nil {
slog.Error("sonarr info: Failed to get server", "error", err)
return arr.MovieSerie{}, errors.New("failed to get server")
}
server, err := getSonarr(arrRequest.ServerName)
if err != nil {
slog.Error("sonarr info: Failed to get server", "error", err)
return arr.MovieSerie{}, errors.New("failed to get server")
}
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
resp, respStatusCode, err := sonarr.GetContent(arrRequest.ArrID)
if err != nil {
slog.Error("sonarr info: Failed to get info", "error", err)
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
slog.Error("sonarr info: 404 returned.. content must've been removed.. removing request.")
err := deleteArrRequest(db, arrRequest.ID)
if err != nil {
return arr.MovieSerie{}, errors.New("failed to delete request for removed content")
} else {
return arr.MovieSerie{}, errors.New("request deleted")
}
}
return arr.MovieSerie{}, errors.New("failed to get info")
}
return resp, nil
}
+85
View File
@@ -0,0 +1,85 @@
package main
import (
"errors"
"log/slog"
"github.com/sbondCo/Watcharr/arr"
"gorm.io/gorm"
)
// Deny an arr request
func denyArrRequest(db *gorm.DB, id uint) error {
resp := db.Model(&ArrRequest{}).Where("id = ?", id).Update("status", ARR_REQUEST_DENIED)
if resp.Error != nil {
slog.Error("denyArrRequest: Failed to update status to denied", "error", resp.Error)
return errors.New("failed when updating request status")
}
return nil
}
// Approve radarr movie
func approveRadarrRequest(db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, error) {
_, err := getArrRequest(db, reqId)
if err != nil {
slog.Error("approveRadarrRequest: Failed to get request from db", "error", err)
return 0, errors.New("failed to get request")
}
// Get server in request
server, err := getRadarr(ur.ServerName)
if err != nil {
slog.Error("approveRadarrRequest: Failed to get server", "error", err)
return 0, errors.New("failed to get server")
}
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
ur.AutomaticSearch = server.AutomaticSearch
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
if err != nil {
slog.Error("approveRadarrRequest: Failed to add content", "error", err)
return 0, errors.New("failed to add content")
}
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
if dbResp.Error != nil {
slog.Error("approveRadarrRequest: Failed to update request in db", "error", err)
return 0, errors.New("content was requested, but we failed to update the db")
}
arrId, ok := resp["id"].(float64)
if !ok {
slog.Error("approveRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
return 0, errors.New("content added, but failed to get arr id")
}
return int(arrId), nil
}
// Approve sonarr movie
func approveSonarrRequest(db *gorm.DB, reqId uint, ur arr.SonarrRequest) (int, error) {
_, err := getArrRequest(db, reqId)
if err != nil {
slog.Error("approveSonarrRequest: Failed to get request from db", "error", err)
return 0, errors.New("failed to get request")
}
// Get server in request
server, err := getSonarr(ur.ServerName)
if err != nil {
slog.Error("approveSonarrRequest: Failed to get server", "error", err)
return 0, errors.New("failed to get server")
}
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
ur.AutomaticSearch = server.AutomaticSearch
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
if err != nil {
slog.Error("approveSonarrRequest: Failed to add content", "error", err)
return 0, errors.New("failed to add content")
}
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
if dbResp.Error != nil {
slog.Error("approveSonarrRequest: Failed to update request in db", "error", err)
return 0, errors.New("content was requested, but we failed to update the db")
}
arrId, ok := resp["id"].(float64)
if !ok {
slog.Error("approveSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
return 0, errors.New("failed to get arr id")
}
return int(arrId), nil
}
+204 -14
View File
@@ -13,7 +13,7 @@ import (
"log/slog"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -28,6 +28,7 @@ type UserType uint8
var (
// Assume watcharr user if none of these...
JELLYFIN_USER UserType = 1
PLEX_USER UserType = 2
)
// User Perms
@@ -38,6 +39,7 @@ const (
PERM_NONE int = 1 << iota
PERM_ADMIN
PERM_REQUEST_CONTENT
PERM_REQUEST_CONTENT_AUTO_APPROVE
)
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
@@ -46,6 +48,9 @@ type User struct {
GormModel
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
Password string `gorm:"not null" json:"password" binding:"required"`
AvatarID uint `json:"-"`
Avatar Image `json:"avatar"`
Bio string `json:"bio"`
// The type of user/which auth service they originate from.
// Empty if from Watcharr, or the name of the service (eg. jellyfin)
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
@@ -53,19 +58,72 @@ type User struct {
ThirdPartyID string `json:"-"`
// Auth token from third party (jellyfin)
ThirdPartyAuth string `json:"-"`
Watched []Watched
Permissions int `gorm:"default:1" json:"-"`
// Users third party integrations (minus jellyfin for now)
UserServices []UserServices `json:"-"`
Watched []Watched
// All Tags
Tags []Tag `json:"-"`
// Users permissions
Permissions int `gorm:"default:1" json:"-"`
// All user settings cols, in another struct for reusability
UserSettings
}
func (u *User) GetSafe() PublicUser {
return PublicUser{
ID: u.ID,
Username: u.Username,
Avatar: u.Avatar,
Bio: u.Bio,
}
}
// This struct uses pointer to the values, so in update user settings,
// we can tell which setting is being updated (if not nil..).
type UserSettings struct {
// Is profile private
Private *bool `gorm:"default:false" json:"private"`
// Are watched list content thoughts public (profile must also be public is false)
PrivateThoughts *bool `gorm:"default:false" json:"privateThoughts"`
// If ui 'spoilers' should be shown
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
// If user wants previously watched items to show in 'Finished' filter,
// even if the watched item state has since been changed.
// Also if user wants to show in watched stats.
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
// User's country to get correct content streaming providers.
Country *string `gorm:"default:'US'" json:"country"`
// Does the user want show, season and episode automations enabled.
AutomateShowStatuses *bool `gorm:"default:true" json:"automateShowStatuses"`
// Rating system user wants to use (frontend only).
// RatingSystem enum in frontend maxes out at 3, so just max=3 on this and we should be gut.
RatingSystem *int `json:"ratingSystem" binding:"omitempty,max=3"`
// Rating step for supported rating systems (frontend only, enum goes up to 2).
RatingStep *int `json:"ratingStep" binding:"omitempty,max=2"`
}
// Holds third party service auth tokens for users.
// Each service may use the fields in their own way.
// Unique index applied between service name and clientID
// to ensure no duplicates (no need to apply it against
// user_id, no accounts should share an integration).
//
// Plex:
// - AuthToken : Used for requests against plex.tv
// - AuthToken2 : Used for requests against home plex server.
type UserServices struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Service/integration name
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
// The users id on the third party service
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
AuthToken string `gorm:"not null;" json:"-"`
// Second auth token, generic name so future services can use it without extra confusion.
// Ex: We require a second auth token for use with our local server for Plex.
AuthToken2 string `json:"-"`
UserID uint `gorm:"not null;" json:"-"`
}
// We use a separate struct for registration to avoid confusion
@@ -101,6 +159,7 @@ type AvailableAuthProvidersResponse struct {
AvailableAuthProviders []string `json:"available"`
SignupEnabled bool `json:"signupEnabled"`
IsInSetup bool `json:"isInSetup"`
UseEmby bool `json:"useEmby"`
}
type ArgonParams struct {
@@ -111,6 +170,16 @@ type ArgonParams struct {
keyLength uint32
}
func GetPassArgonParams() *ArgonParams {
return &ArgonParams{
memory: 64 * 1024,
iterations: 3,
parallelism: 2,
saltLength: 16,
keyLength: 32,
}
}
type TokenClaims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
@@ -118,6 +187,11 @@ type TokenClaims struct {
jwt.RegisteredClaims
}
type UserPasswordUpdateRequest struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required"`
}
// Auth middleware
// If db is passed, extra user info from the database will be fetched.
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
@@ -132,7 +206,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
}
// Parse token
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
return []byte(Config.JWT_SECRET), nil
})
if err != nil {
slog.Error("AuthRequired failed to parse token", "error", err)
@@ -193,6 +267,21 @@ func AdminRequired() gin.HandlerFunc {
}
}
// Specific perm only middleware (use after AuthRequired with extra info!)
func PermRequired(perm int) gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetUint("userId")
perms := c.GetInt("userPermissions")
if hasPermission(perms, perm) {
slog.Debug("PermRequired: User has permission to access perm only route", "user_id", userId, "required_perm", perm)
c.Next()
return
}
slog.Info("PermRequired: User denied permission to access perm only route", "user_id", userId, "required_perm", perm)
c.AbortWithStatus(401)
}
}
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
if !Config.SIGNUP_ENABLED {
slog.Warn("Register called, but signing up is disabled.")
@@ -200,13 +289,7 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
}
var user User = User{Username: ur.Username, Password: ur.Password}
slog.Info("A user is registering", "username", user.Username)
hash, err := hashPassword(user.Password, &ArgonParams{
memory: 64 * 1024,
iterations: 3,
parallelism: 2,
saltLength: 16,
keyLength: 32,
})
hash, err := hashPassword(user.Password, GetPassArgonParams())
if err != nil {
log.Fatal(err)
}
@@ -220,10 +303,12 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
user.Permissions = initialPerm
}
user.Country = &Config.DEFAULT_COUNTRY
res := db.Create(&user)
if res.Error != nil {
// If error is because unique contraint failed.. user already exists
if strings.Contains(res.Error.Error(), "UNIQUE") {
if res.Error == gorm.ErrDuplicatedKey {
slog.Error("Registration failed", "error", res.Error.Error(), "error_pretty", "User already exists")
return AuthResponse{}, errors.New("User already exists")
}
@@ -342,7 +427,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
}
dbUser := new(User)
dbRes := db.Where("third_party_id = ?", resp.User.ID).Take(&dbUser)
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, JELLYFIN_USER).Take(&dbUser)
if dbRes.Error != nil {
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
// Record not found, so we should create the user
@@ -351,6 +436,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
dbUser.ThirdPartyAuth = resp.AccessToken
dbUser.Username = resp.User.Name
dbUser.Type = JELLYFIN_USER
dbUser.Country = &Config.DEFAULT_COUNTRY
dbRes = db.Create(&dbUser)
if dbRes.Error != nil {
@@ -376,6 +462,75 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
return AuthResponse{Token: token}, nil
}
// Login via Plex.
func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
slog.Error("Request made to login via Plex, but Plex authentication is disabled")
return AuthResponse{}, errors.New("plex login not enabled")
}
slog.Debug("A Plex User Is Logging In")
account, err := fetchPlexAccountFromToken(lr.AuthToken)
if err != nil {
slog.Error("loginPlex: Could not fetch Plex account", "error", err)
return AuthResponse{}, errors.New("could not fetch plex acount")
}
if account.Username == "" || account.Id == 0 {
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
return AuthResponse{}, errors.New("data is missing from the plex account response")
}
// Get users auth token against our home plex server.
// If no auth token, assume they don't have access to our plex server.
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
if err != nil || homeAuthToken == "" {
slog.Error("loginPlex: Failed to get home server auth token for user! If not because the request failed, then ensure the user has access to our home servers library.", "error", err)
return AuthResponse{}, errors.New("failed to verify plex access")
}
dbUser := new(User)
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
if dbRes.Error != nil {
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
dbUser.Username = account.Username
dbUser.Type = PLEX_USER
dbUser.UserServices = append(dbUser.UserServices, UserServices{
Name: "plex",
ClientID: strconv.FormatUint(account.Id, 10),
AuthToken: lr.AuthToken,
AuthToken2: homeAuthToken,
})
dbUser.Country = &Config.DEFAULT_COUNTRY
dbRes = db.Create(&dbUser)
if dbRes.Error != nil {
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
return AuthResponse{}, errors.New("failed to create new user from plex")
}
} else {
slog.Error("loginPlex: Failed to select user from database for login", "error", dbRes.Error)
return AuthResponse{}, errors.New("failed to locate user")
}
} else {
// If user exists.. update their access tokens in db
for i, v := range dbUser.UserServices {
if v.Name == "plex" {
slog.Info("loginPlex: Found plex user service.. attemping to update")
dbUser.UserServices[i].AuthToken = lr.AuthToken
if homeAuthToken != "" {
dbUser.UserServices[i].AuthToken2 = homeAuthToken
}
break
}
}
db.Save(&dbUser.UserServices)
}
token, err := signJWT(dbUser)
if err != nil {
slog.Error("loginPlex: Failed to sign new jwt", "error", err)
return AuthResponse{}, errors.New("failed to get auth token")
}
return AuthResponse{Token: token}, nil
}
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
var dbToken Token
resp := db.Where("value = ?", req.Token).Take(&dbToken)
@@ -431,7 +586,7 @@ func signJWT(user *User) (token string, err error) {
})
// Sign and get the complete encoded token as a string using the secret
return jwt.SignedString([]byte(os.Getenv("JWT_SECRET")))
return jwt.SignedString([]byte(Config.JWT_SECRET))
}
func hashPassword(password string, p *ArgonParams) (encodedHash string, err error) {
@@ -525,3 +680,38 @@ func hasPermission(perms int, reqPerm int) bool {
}
return (perms & reqPerm) == reqPerm
}
func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
slog.Debug("userChangePassword request running", "user_id", userId)
user := new(User)
res := db.Where("id = ?", userId).Select("password").Take(&user)
if res.Error != nil {
slog.Error("userChangePassword failed - failed to retrieve user from database", "user_id", userId, "error", res.Error)
return errors.New("failed to retrieve user")
}
slog.Debug("userChangePassword user found", "user_id", userId)
match, err := compareHash(pwds.OldPassword, user.Password)
if err != nil {
slog.Error("userChangePassword failed - failed to compare passwords", "user_id", userId, "error", err)
return errors.New("failed to compare passwords")
}
if !match {
slog.Error("userChangePassword failed - current password hash doesn't match password hash in database", "user_id", userId, "error", err)
return errors.New("current password provided doesn't match password in database")
}
slog.Debug("userChangePassword hash for current password matches hash in the database", "user_id", userId)
slog.Debug("userChangePassword hashing new password", "user_id", userId)
hash, err := hashPassword(pwds.NewPassword, GetPassArgonParams())
if err != nil {
slog.Error("userChangePassword failed - failed to hash new password", "user_id", userId, "error", err)
return errors.New("failed to hash new password")
}
slog.Debug("userChangePassword new password hashed", "user_id", userId)
if err := db.Model(&User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
slog.Error("userChangePassword failed - failed to update password in database", "user_id", userId, "error", err)
return errors.New("failed to update password")
} else {
slog.Debug("userChangePassword password updated", "user_id", userId)
}
return nil
}
+101 -30
View File
@@ -6,29 +6,60 @@ import (
"log"
"log/slog"
"os"
"path"
"time"
"github.com/sbondCo/Watcharr/game"
"gorm.io/gorm"
)
var DataPath = func() string {
path := os.Getenv("WATCHARR_DATA")
if path == "" {
path = "./data"
}
return path
}()
type ServerConfig struct {
// Used to sign JWT tokens. Make sure to make
// it strong, just like a very long, complicated password.
JWT_SECRET string `json:",omitempty"`
// Default country for new users. This is used to set the default
// region to get correct content streaming providers.
DEFAULT_COUNTRY string `json:",omitempty"`
// Optional: Point to your Jellyfin install
// to enable it as an auth provider.
JELLYFIN_HOST string `json:",omitempty"`
// Optional: Use Emby instead of Jellyfin branding in the ui.
USE_EMBY bool
// Enable/disable signup functionality.
// Set to `false` to disable registering an account.
SIGNUP_ENABLED bool `json:",omitempty"`
SIGNUP_ENABLED bool
// Optional: Provide your own TMDB API Key.
// If unprovided, the default Watcharr API key will be used.
TMDB_KEY string `json:",omitempty"`
// Optional: Point to Plex install to enable plex features.
PLEX_HOST string `json:",omitempty"`
// Optional: Machine identifier of your Plex server.
// This is used to ensure only users of your Plex server
// can use this Watcharr instance.
// Will be fetched automatically when PLEX_HOST is provided via web ui.
PLEX_MACHINE_ID string `json:",omitempty"`
SONARR []SonarrSettings `json:",omitempty"`
RADARR []RadarrSettings `json:",omitempty"`
TWITCH game.IGDB `json:",omitempty"`
// Optional: Schedule for tasks.
TASK_SCHEDULE map[string]int `json:",omitempty"`
// Enable/disable debug logging. Useful for when trying
// to figure out exactly what the server is doing at a point
@@ -45,27 +76,32 @@ type ServerConfig struct {
// not editable on frontend, so not needed).
func (c *ServerConfig) GetSafe() ServerConfig {
return ServerConfig{
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
JELLYFIN_HOST: c.JELLYFIN_HOST,
TMDB_KEY: c.TMDB_KEY,
DEBUG: c.DEBUG,
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
DEFAULT_COUNTRY: c.DEFAULT_COUNTRY,
JELLYFIN_HOST: c.JELLYFIN_HOST,
USE_EMBY: c.USE_EMBY,
TMDB_KEY: c.TMDB_KEY,
PLEX_HOST: c.PLEX_HOST,
PLEX_MACHINE_ID: c.PLEX_MACHINE_ID,
DEBUG: c.DEBUG,
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
TWITCH: game.IGDB{
ClientID: c.TWITCH.ClientID,
ClientSecret: c.TWITCH.ClientSecret,
}, // Dont act safe, this contains twitch secrets, needed for config
}
}
var (
// Our server config.. set defaults here, then `readConfig`
// will overwrite if provided in watcharr.json cfg file.
Config = ServerConfig{
SIGNUP_ENABLED: true,
}
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
Config = ServerConfig{}
)
// Read config file
// Calls generateConfig if file doesn't exist
func readConfig() error {
cfg, err := os.Open("./data/watcharr.json")
cfg, err := os.Open(path.Join(DataPath, "watcharr.json"))
if err != nil {
if os.IsNotExist(err) {
slog.Info("Config file doesn't exist... generating.")
@@ -93,20 +129,24 @@ func initFromConfig() error {
}
// Generate new barebones watcharr.json config file.
// Currently only JWT_SECRET is required, so this method
// generates a secret.
// Generates a JWT_SECRET and set default config.
func generateConfig() error {
key, err := generateString(64)
if err != nil {
return err
}
cfg := ServerConfig{JWT_SECRET: key}
cfg := ServerConfig{
JWT_SECRET: key,
// Other defaults..
DEFAULT_COUNTRY: "US",
SIGNUP_ENABLED: true,
}
barej, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
return err
}
Config.JWT_SECRET = cfg.JWT_SECRET
return os.WriteFile("./data/watcharr.json", barej, 0755)
Config = cfg
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
}
// Update server config property
@@ -117,6 +157,8 @@ func updateConfig(k string, v any) error {
}
if k == "JELLYFIN_HOST" {
Config.JELLYFIN_HOST = v.(string)
} else if k == "USE_EMBY" {
Config.USE_EMBY = v.(bool)
} else if k == "SIGNUP_ENABLED" {
Config.SIGNUP_ENABLED = v.(bool)
} else if k == "TMDB_KEY" {
@@ -124,6 +166,8 @@ func updateConfig(k string, v any) error {
} else if k == "DEBUG" {
Config.DEBUG = v.(bool)
setLoggingLevel()
} else if k == "DEFAULT_COUNTRY" {
Config.DEFAULT_COUNTRY = v.(string)
} else {
return errors.New("invalid setting")
}
@@ -136,12 +180,13 @@ func writeConfig() error {
if err != nil {
return err
}
return os.WriteFile("./data/watcharr.json", barej, 0755)
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
}
type ServerFeatures struct {
Sonarr bool `json:"sonarr"`
Radarr bool `json:"radarr"`
Games bool `json:"games"`
}
// Get enabled server functionality from Config.
@@ -149,20 +194,46 @@ type ServerFeatures struct {
// which btns should be shown, etc.
func getEnabledFeatures(userPerms int) ServerFeatures {
var f ServerFeatures
// https://github.com/sbondCo/Watcharr/issues/211
// Currently requesting permissions have not been setup, only admins for now.
if !hasPermission(userPerms, PERM_ADMIN) {
return f
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
f.Games = true
}
if len(Config.SONARR) > 0 {
f.Sonarr = true
}
if len(Config.RADARR) > 0 {
f.Radarr = true
if hasPermission(userPerms, PERM_REQUEST_CONTENT) {
if len(Config.SONARR) > 0 {
f.Sonarr = true
}
if len(Config.RADARR) > 0 {
f.Radarr = true
}
}
return f
}
func saveTwitchConfig(c game.IGDB) error {
// If existing client id and secret are same.. just return here
if (Config.TWITCH.ClientID != nil && c.ClientID != nil && Config.TWITCH.ClientSecret != nil && c.ClientSecret != nil) &&
*Config.TWITCH.ClientID == *c.ClientID && *Config.TWITCH.ClientSecret == *c.ClientSecret {
slog.Info("saveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
return nil
}
// Update our config
Config.TWITCH.ClientID = c.ClientID
Config.TWITCH.ClientSecret = c.ClientSecret
Config.TWITCH.AccessToken = ""
Config.TWITCH.AccessTokenExpires = time.Time{}
// Try to init again
err := Config.TWITCH.Init()
if err != nil {
slog.Error("saveTwitchConfig failed to initialize TWITCH", "error", err)
return errors.New("initialization with credentials failed")
}
err = writeConfig()
if err != nil {
slog.Error("saveTwitchConfig failed to write config", "error", err)
return errors.New("failed to save config")
}
return nil
}
type ServerStats struct {
Users int64 `json:"users"`
PrivateUsers int64 `json:"privateUsers"`
@@ -204,13 +275,13 @@ func getServerStats(db *gorm.DB) ServerStats {
if resp.Error != nil {
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
} else {
stats.MostWatchedShow = w.Content
stats.MostWatchedShow = *w.Content
}
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
if resp.Error != nil {
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
} else {
stats.MostWatchedMovie = w.Content
stats.MostWatchedMovie = *w.Content
}
return stats
}
+269 -8
View File
@@ -1,9 +1,16 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"path"
"strconv"
"time"
"github.com/gin-contrib/cache/persistence"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ContentType string
@@ -13,6 +20,8 @@ const (
SHOW ContentType = "tv"
)
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
// For storing cached content, so we can serve the basic local data for watched list to work
type Content struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
@@ -34,6 +43,182 @@ type Content struct {
NumberOfSeasons uint32 `json:"numberOfSeasons"`
}
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
return errors.New("content missing id or title")
}
var res *gorm.DB
if onlyUpdate {
// We only want to update an existing row, if it exists.
res = db.Model(&Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
if res.Error != nil {
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
return errors.New("failed to update cached content in database")
}
} else {
// On conflict, update existing row with details incase any were updated/missing.
res = db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
DoUpdates: clause.AssignmentColumns([]string{
"title",
"poster_path",
"overview",
"release_date",
"popularity",
"vote_average",
"vote_count",
"imdb_id",
"status",
"budget",
"revenue",
"runtime",
"number_of_episodes",
"number_of_seasons",
}),
}).Create(&c)
if res.Error != nil {
// Error if anything but unique contraint error
if res.Error != gorm.ErrDuplicatedKey {
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
return errors.New("failed to cache content in database")
}
}
}
// If row created, download the image
if res.RowsAffected > 0 {
slog.Debug("saveContent: Downloading poster.")
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath))
if err != nil {
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
}
}
return nil
}
func cacheContentTv(db *gorm.DB, content TMDBShowDetails, onlyUpdate bool) (Content, error) {
slog.Debug("cacheContentTv", "content", content)
var (
releaseDate time.Time
runtime uint32
)
var dateFormat = "2006-01-02"
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
if err != nil {
slog.Error("Failed to parse tv release date", "error", err)
}
if len(content.EpisodeRunTime) > 0 {
runtime = uint32(content.EpisodeRunTime[0])
}
c := Content{
TmdbID: content.ID,
Title: content.Name,
Overview: content.Overview,
PosterPath: content.PosterPath,
Type: SHOW,
ReleaseDate: &releaseDate,
Popularity: content.Popularity,
VoteAverage: content.VoteAverage,
VoteCount: content.VoteCount,
Status: content.Status,
Runtime: runtime,
NumberOfEpisodes: content.NumberOfEpisodes,
NumberOfSeasons: content.NumberOfSeasons,
}
err = saveContent(db, &c, onlyUpdate)
if err != nil {
slog.Error("cacheContentTv: Failed to save content!", "error", err)
return Content{}, errors.New("failed to save content")
}
return c, nil
}
func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (Content, error) {
var (
releaseDate time.Time
)
var dateFormat = "2006-01-02"
// Get details from movie/show response and fill out needed vars
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
if err != nil {
slog.Error("Failed to parse movie release date", "error", err)
}
c := Content{
TmdbID: content.ID,
Title: content.Title,
Overview: content.Overview,
PosterPath: content.PosterPath,
Type: MOVIE,
ReleaseDate: &releaseDate,
Popularity: content.Popularity,
VoteAverage: content.VoteAverage,
VoteCount: content.VoteCount,
ImdbID: content.ImdbID,
Status: content.Status,
Budget: content.Budget,
Revenue: content.Revenue,
Runtime: content.Runtime,
}
err = saveContent(db, &c, onlyUpdate)
if err != nil {
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
return Content{}, errors.New("failed to save content")
}
return c, nil
}
// Get content from our cache, or cache it if it doesn't exist.
func getOrCacheContent(db *gorm.DB, contentType ContentType, tmdbId int) (Content, error) {
var content Content
// Look in db for content.
db.Where("type = ? AND tmdb_id = ?", contentType, tmdbId).Find(&content)
// Create content if not found from our db.
if content == (Content{}) {
slog.Debug("Content not in db, fetching...", "type", contentType, "tmdbId", tmdbId)
resp, err := tmdbAPIRequest("/"+string(contentType)+"/"+strconv.Itoa(tmdbId), map[string]string{})
if err != nil {
slog.Error("getOrCacheContent: content tmdb api request failed", "error", err)
return Content{}, errors.New("failed to find requested media")
}
if contentType == "movie" {
c := new(TMDBMovieDetails)
err := json.Unmarshal([]byte(resp), &c)
if err != nil {
slog.Error("Failed to unmarshal movie details", "error", err)
return Content{}, errors.New("failed to process movie details response")
}
content, err = cacheContentMovie(db, *c, false)
if err != nil {
slog.Error("getOrCacheContent: failed to cache movie content", "type", contentType, "content_id", tmdbId, "err", err)
return Content{}, errors.New("failed to cache content")
}
} else {
c := new(TMDBShowDetails)
err := json.Unmarshal(resp, &c)
if err != nil {
slog.Error("Failed to unmarshal tv details", "error", err)
return Content{}, errors.New("failed to process tv details response")
}
content, err = cacheContentTv(db, *c, false)
if err != nil {
slog.Error("getOrCacheContent: failed to cache tv content", "type", contentType, "content_id", tmdbId, "err", err)
return Content{}, errors.New("failed to cache content")
}
}
}
return content, nil
}
// Getting only region needed from api is not a feature yet
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
// When it is, this can be removed for that instead.
@@ -58,9 +243,12 @@ func transformProviders(c *interface{}, country string) {
}
}
func searchContent(query string) (TMDBSearchMultiResponse, error) {
func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
resp := new(TMDBSearchMultiResponse)
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete multi search request!", "error", err.Error())
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
@@ -68,7 +256,55 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
return *resp, nil
}
func movieDetails(id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
resp := new(TMDBSearchMoviesResponse)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete movie search request!", "error", err.Error())
return TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
}
for i := range resp.Results {
resp.Results[i].MediaType = "movie"
}
return *resp, nil
}
func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
resp := new(TMDBSearchShowsResponse)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete tv search request!", "error", err.Error())
return TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
}
for i := range resp.Results {
resp.Results[i].MediaType = "tv"
}
return *resp, nil
}
func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
resp := new(TMDBSearchPeopleResponse)
if pageNum == 0 {
pageNum = 1
}
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
if err != nil {
slog.Error("Failed to complete people search request!", "error", err.Error())
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
}
for i := range resp.Results {
resp.Results[i].MediaType = "person"
}
return *resp, nil
}
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
resp := new(TMDBMovieDetails)
err := tmdbRequest("/movie/"+id, rParams, &resp)
if err != nil {
@@ -76,6 +312,7 @@ func movieDetails(id string, country string, rParams map[string]string) (TMDBMov
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
}
transformProviders(&resp.WatchProviders, country)
go cacheContentMovie(db, *resp, true)
return *resp, nil
}
@@ -89,7 +326,7 @@ func movieCredits(id string) (TMDBContentCredits, error) {
return *resp, nil
}
func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
resp := new(TMDBShowDetails)
err := tmdbRequest("/tv/"+id, rParams, &resp)
if err != nil {
@@ -97,6 +334,7 @@ func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDe
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
}
transformProviders(&resp.WatchProviders, country)
go cacheContentTv(db, *resp, true)
return *resp, nil
}
@@ -104,19 +342,32 @@ func tvCredits(id string) (TMDBContentCredits, error) {
resp := new(TMDBContentCredits)
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete tv cast request!", err.Error())
slog.Error("Failed to complete tv cast request!", "error", err.Error())
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
}
return *resp, nil
}
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
var cacheKey = "contentstore-seasondetails-" + tvId + "-" + seasonNumber
resp := new(TMDBSeasonDetails)
if err := ContentStore.Get(cacheKey, &resp); err != nil {
if err != persistence.ErrCacheMiss {
slog.Error("seasonDetails: Cache failed for some reason", "error", err)
}
} else {
slog.Debug("seasonDetails: Returning cache.")
return *resp, nil
}
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete season details request!", "error", err.Error())
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
}
if err := ContentStore.Set(cacheKey, resp, time.Hour*24); err != nil {
slog.Error("seasonDetails: Failed to set cache!", "error", err)
}
return *resp, nil
}
@@ -142,7 +393,7 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
func discoverMovies() (TMDBDiscoverMovies, error) {
resp := new(TMDBDiscoverMovies)
err := tmdbRequest("/discover/movie/", map[string]string{"page": "1"}, &resp)
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete discover movies request!", "error", err.Error())
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
@@ -152,7 +403,7 @@ func discoverMovies() (TMDBDiscoverMovies, error) {
func discoverTv() (TMDBDiscoverShows, error) {
resp := new(TMDBDiscoverShows)
err := tmdbRequest("/discover/tv/", map[string]string{"page": "1"}, &resp)
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete discover tv request!", "error", err.Error())
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
@@ -193,3 +444,13 @@ func upcomingTv() (TMDBUpcomingShows, error) {
}
return *resp, nil
}
func regions() (TMDBRegions, error) {
resp := new(TMDBRegions)
err := tmdbRequest("/watch/providers/regions", map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete regions request!", "error", err.Error())
return TMDBRegions{}, errors.New("failed to complete regions request")
}
return *resp, nil
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"errors"
"log/slog"
"time"
"gorm.io/gorm"
)
// Database struct, only internal.
type Follow struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"-"`
UserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null;check:user_id != followed_user_id" json:"-"`
User User `json:"-"`
FollowedUserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null" json:"-"`
FollowedUser User `json:"-"`
}
// For end users to see.
type FollowPublic struct {
CreatedAt time.Time `json:"createdAt"`
FollowedUser PublicUser `json:"followedUser"`
}
type FollowThoughts struct {
FollowedUser PublicUser `json:"followedUser"`
Thoughts string `json:"thoughts"`
Status WatchedStatus `json:"status"`
Rating float64 `json:"rating"`
}
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
res := db.Model(&Follow{}).Create(&f)
if res.Error != nil {
slog.Error("followUser: Error on inserting follow.", "error", res.Error)
err := "failed to insert follow"
if res.Error == gorm.ErrDuplicatedKey {
err = "already followed"
}
return FollowPublic{}, errors.New(err)
}
// Now get the row with preloaded followed user
var nf Follow
res = db.Where("user_id = ? AND followed_user_id = ?", currentUserId, toFollowUserId).Preload("FollowedUser", "private = ?", 0).Take(&nf)
if res.Error != nil {
slog.Error("followUser: Couldn't fetch newly followed user.", "error", res.Error)
return FollowPublic{}, errors.New("followed, but failed to fetch followed user")
}
return FollowPublic{CreatedAt: nf.CreatedAt, FollowedUser: nf.FollowedUser.GetSafe()}, nil
}
func unfollowUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (bool, error) {
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
res := db.Delete(&f)
if res.Error != nil {
slog.Error("unfollowUser: Error deleting follow.", "error", res.Error)
err := "failed to remove follow"
if res.Error == gorm.ErrRecordNotFound {
err = "not following"
}
return false, errors.New(err)
}
return true, nil
}
// Get current users follows
func getFollows(db *gorm.DB, userId uint) ([]FollowPublic, error) {
var follows []Follow
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ?", 0).Find(&follows)
if res.Error != nil {
slog.Error("getFollows: Error finding follows.", "error", res.Error)
return []FollowPublic{}, errors.New("failed to find follows")
}
fpub := []FollowPublic{}
for _, v := range follows {
// Skip followed users without an ID..
// this will be because they have made
// their account private after we followed them.
if v.FollowedUser.ID == 0 {
continue
}
fpub = append(fpub, FollowPublic{CreatedAt: v.CreatedAt, FollowedUser: v.FollowedUser.GetSafe()})
}
return fpub, nil
}
// Get followed profile thoughts, rating, etc on specific content.
func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId string) ([]FollowThoughts, error) {
var follows []Follow
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ? AND private_thoughts = ?", 0, 0).Find(&follows)
if res.Error != nil {
slog.Error("getFollows: Error finding follows.", "error", res.Error)
return []FollowThoughts{}, errors.New("failed to find follows")
}
slog.Info("getFollowsThoughts")
var followIds []uint
for _, v := range follows {
// Skip empty followedUsers.. they are private.
if v.FollowedUser.ID == 0 {
continue
}
followIds = append(followIds, v.FollowedUser.ID)
}
var contentOrGameId int
if mediaType == "game" {
// Get our content id from type and tmdbId
var content Game
res = db.Where("igdb_id = ?", mediaId).Select("id").Find(&content)
if res.Error != nil {
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
return []FollowThoughts{}, errors.New("failed to find content")
}
contentOrGameId = content.ID
} else if mediaType == "movie" || mediaType == "tv" {
// Get our content id from type and tmdbId
var content Content
res = db.Where("type = ? AND tmdb_id = ?", mediaType, mediaId).Select("id").Find(&content)
if res.Error != nil {
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
return []FollowThoughts{}, errors.New("failed to find content")
}
contentOrGameId = content.ID
} else {
slog.Error("getFollows: Unrecognized media type (movie, tv or game supported).", "media_type", mediaType)
return []FollowThoughts{}, errors.New("unrecognized media type")
}
// Get list of followeds watcheds for this content
var fw []Watched
if mediaType == "game" {
res = db.Where("game_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
} else {
res = db.Where("content_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
}
if res.Error != nil {
slog.Error("getFollows: Error finding followed watcheds from db.", "error", res.Error)
return []FollowThoughts{}, errors.New("failed to find followed watcheds")
}
// Create followThoughts array by combining follows and fw(atcheds)
ft := []FollowThoughts{}
for _, v := range fw {
var fu PublicUser
for _, f := range follows {
if f.FollowedUser.ID == v.UserID {
fu = f.FollowedUser.GetSafe()
break
}
}
// If we didn't find a related followedUser.. skip this watched entry
if fu.ID == 0 {
continue
}
ft = append(ft, FollowThoughts{FollowedUser: fu, Thoughts: v.Thoughts, Status: v.Status, Rating: v.Rating})
}
return ft, nil
}
+276
View File
@@ -0,0 +1,276 @@
package game
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"time"
)
const (
igdbHost = "https://api.igdb.com/v4"
tokenGrantType = "client_credentials"
)
var tokenRefreshJobCancel context.CancelFunc
type IGDB struct {
ClientID *string `json:"clientId,omitempty"`
ClientSecret *string `json:"clientSecret,omitempty"`
AccessToken string `json:"accessToken,omitempty"`
AccessTokenExpires time.Time `json:"accessTokenExpires,omitempty"`
onTokenRefreshed *func()
}
func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp interface{}) error {
// if using igdb host and we have no access token, error before running req
if host == igdbHost && (i.ClientID == nil || i.AccessToken == "") {
return errors.New("using igdbHost without a clientID or accessToken")
}
base, err := url.Parse(host)
if err != nil {
return errors.New("failed to parse api uri")
}
// Path params
base.Path += ep
// Query params
params := url.Values{}
for k, v := range p {
params.Add(k, v)
}
// Add params to url
base.RawQuery = params.Encode()
slog.Info("req", "base", base.String())
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer([]byte(b)))
if err != nil {
return err
}
// Add igdb auth headers
if host == igdbHost {
req.Header.Add("Client-ID", *i.ClientID)
req.Header.Add("Authorization", "Bearer "+i.AccessToken)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return err
}
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
slog.Error("game non 2xx status code:", "status_code", res.StatusCode)
return errors.New(string(body))
}
err = json.Unmarshal([]byte(body), &resp)
if err != nil {
return err
}
return nil
}
func (i *IGDB) getNewAccessToken() (TwitchTokenResponse, error) {
var resp TwitchTokenResponse
err := i.req(
"https://id.twitch.tv/oauth2",
"/token",
map[string]string{"client_id": *i.ClientID, "client_secret": *i.ClientSecret, "grant_type": tokenGrantType},
"",
&resp,
)
if err != nil {
slog.Error("IGDB init token request failed", "error", err)
return TwitchTokenResponse{}, errors.New("token request failed, check client id and secret")
}
return resp, nil
}
func (i *IGDB) refreshToken(ctx context.Context) {
var exp <-chan time.Time
if i.AccessToken != "" && !i.AccessTokenExpires.IsZero() && i.AccessTokenExpires.Compare(time.Now()) == 1 {
// Stored token not expired.. set exp time to token exp time - 1h.
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
} else {
// Token expired.. exp now..
exp = time.After(100 * time.Millisecond)
}
slog.Info("refreshToken running")
for {
select {
case <-exp:
slog.Info("IGDB refreshToken: Token expired (or is near expiry date)")
r, err := i.getNewAccessToken()
if err != nil {
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):", err)
exp = time.After(60 * time.Second)
} else {
slog.Info("IGDB refreshToken: Token successfully refreshed")
i.AccessToken = r.AccessToken
i.AccessTokenExpires = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second)
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
// Call token refresh callback if set
if i.onTokenRefreshed != nil {
(*i.onTokenRefreshed)()
}
}
case <-ctx.Done():
slog.Info("refreshToken cancelled")
return
}
}
}
func (i *IGDB) OnTokenRefreshed(tokenRefreshed func()) {
i.onTokenRefreshed = &tokenRefreshed
}
// Get token and stuff
func (i *IGDB) Init() error {
// Cancel existing refresh job if we have a cancel func for its context
if tokenRefreshJobCancel != nil {
slog.Debug("IGDB init: Refresh job running.. cancelling it.")
tokenRefreshJobCancel()
tokenRefreshJobCancel = nil
}
// Stop here if we have no client id or secret.
if i.ClientID == nil || i.ClientSecret == nil {
slog.Error("IGDB init client id and or secret not provided")
return errors.New("client id and or secret not provided")
}
slog.Debug("IGDB init running.")
ctx, cancel := context.WithCancel(context.Background())
tokenRefreshJobCancel = cancel
// Get and set first token if needed
go i.refreshToken(ctx)
return nil
}
func (i *IGDB) Search(q string) (GameSearchResponse, error) {
slog.Debug("IGDB Search called", "query", q)
var resp GameSearchResponse
err := i.req(
igdbHost,
"/games",
map[string]string{},
"fields name, cover.image_id, version_title, summary, first_release_date; search \""+q+"\";",
&resp,
)
if err != nil {
slog.Error("IGDB Search request failed!", "error", err)
return GameSearchResponse{}, errors.New("request failed")
}
return resp, nil
}
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
slog.Debug("IGDB GameDetails called", "id", id)
var resp []GameDetailsResponse
err := i.req(
igdbHost,
"/games",
map[string]string{},
`fields
name,
cover.image_id,
version_title,
summary,
storyline,
first_release_date,
artworks.width,
artworks.height,
artworks.image_id,
category,
platforms.name,
game_modes.name,
genres.id,
genres.name,
involved_companies.developer,
involved_companies.publisher,
involved_companies.porting,
involved_companies.supporting,
involved_companies.company.name,
involved_companies.company.description,
involved_companies.company.slug,
involved_companies.company.websites.category,
involved_companies.company.websites.trusted,
involved_companies.company.websites.url,
rating,
rating_count,
status,
url,
websites.trusted,
websites.category,
websites.url,
videos.name,
videos.video_id,
similar_games.id,
similar_games.name,
similar_games.summary,
similar_games.cover.image_id,
similar_games.first_release_date;
where id = `+id+";",
&resp,
)
if err != nil {
slog.Error("IGDB GameDetails request failed!", "error", err)
return GameDetailsResponse{}, errors.New("request failed")
}
if len(resp) > 0 {
return resp[0], nil
}
return GameDetailsResponse{}, errors.New("no game details recieved")
}
// Basic game details for when we are using them only to update our cache.
// In these cases, it's a waste to ask for everything, when we don't need it.
func (i *IGDB) GameDetailsBasic(id string) (GameDetailsBasicResponse, error) {
slog.Debug("IGDB GameDetails called", "id", id)
var resp []GameDetailsBasicResponse
err := i.req(
igdbHost,
"/games",
map[string]string{},
`fields
name,
cover.image_id,
summary,
storyline,
first_release_date,
category,
platforms.name,
game_modes.name,
genres.name,
rating,
rating_count,
status;
where id = `+id+";",
&resp,
)
if err != nil {
slog.Error("IGDB GameDetails request failed!", "error", err)
return GameDetailsBasicResponse{}, errors.New("request failed")
}
if len(resp) > 0 {
return resp[0], nil
}
return GameDetailsBasicResponse{}, errors.New("no game details recieved")
}
+139
View File
@@ -0,0 +1,139 @@
package game
import (
"encoding/json"
"time"
)
type TwitchTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
// So we can unmarshall the unix timestamps returned from igdb into time.Time.
type UnixTime struct {
time.Time
}
func (u *UnixTime) UnmarshalJSON(b []byte) error {
var timestamp int64
err := json.Unmarshal(b, &timestamp)
if err != nil {
return err
}
u.Time = time.Unix(timestamp, 0)
return nil
}
// Only the fields we request included in each struct
type GameSearchResponse []struct {
ID int `json:"id"`
Cover struct {
ID int `json:"id"`
ImageID string `json:"image_id"`
} `json:"cover"`
FirstReleaseDate UnixTime `json:"first_release_date"`
Name string `json:"name"`
Summary string `json:"summary,omitempty"`
VersionTitle string `json:"version_title,omitempty"`
}
type GameDetailsResponse struct {
ID int `json:"id"`
Artworks []struct {
Width int `json:"width"`
Height int `json:"height"`
ImageID string `json:"image_id"`
} `json:"artworks"`
Category int `json:"category"`
Cover struct {
ID int `json:"id"`
ImageID string `json:"image_id"`
} `json:"cover"`
FirstReleaseDate UnixTime `json:"first_release_date"`
GameModes []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"game_modes"`
Genres []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"genres"`
InvolvedCompanies []struct {
ID int `json:"id"`
Company struct {
ID int `json:"id"`
Description string `json:"description"`
Name string `json:"name"`
Slug string `json:"slug"`
Websites []struct {
ID int `json:"id"`
Category int `json:"category"`
Trusted bool `json:"trusted"`
URL string `json:"url"`
} `json:"websites"`
} `json:"company"`
Developer bool `json:"developer"`
Porting bool `json:"porting"`
Publisher bool `json:"publisher"`
Supporting bool `json:"supporting"`
} `json:"involved_companies"`
Name string `json:"name"`
Platforms []struct {
ID int `json:"id"`
Name string `json:"name"`
} `json:"platforms"`
Rating float64 `json:"rating"`
RatingCount int `json:"rating_count"`
Summary string `json:"summary"`
Storyline string `json:"storyline"`
Status int `json:"status"`
URL string `json:"url"`
Videos []struct {
ID int `json:"id"`
Name string `json:"name"`
VideoID string `json:"video_id"`
} `json:"videos"`
Websites []struct {
ID int `json:"id"`
Category int `json:"category"`
Trusted bool `json:"trusted"`
URL string `json:"url"`
} `json:"websites"`
SimilarGame []struct {
ID int `json:"id"`
Name string `json:"name"`
Summary string `json:"summary"`
FirstReleaseDate UnixTime `json:"first_release_date"`
Cover struct {
ID int `json:"id"`
ImageID string `json:"image_id"`
} `json:"cover"`
} `json:"similar_games"`
}
type GameDetailsBasicResponse struct {
ID int `json:"id"`
Category int `json:"category"`
Cover struct {
ImageID string `json:"image_id"`
} `json:"cover"`
FirstReleaseDate UnixTime `json:"first_release_date"`
GameModes []struct {
Name string `json:"name"`
} `json:"game_modes"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
Name string `json:"name"`
Platforms []struct {
Name string `json:"name"`
} `json:"platforms"`
Rating float64 `json:"rating"`
RatingCount int `json:"rating_count"`
Summary string `json:"summary"`
Storyline string `json:"storyline"`
Status int `json:"status"`
}
+212
View File
@@ -0,0 +1,212 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"strconv"
"time"
"github.com/sbondCo/Watcharr/game"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// For storing cached games, so we can serve the basic local data for watched list to work
type Game struct {
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
UpdatedAt time.Time `json:"updatedAt"`
IgdbID int `json:"igdbId" gorm:"uniqueIndex;not null"`
Name string `json:"name"`
CoverID string `json:"coverId"`
Summary string `json:"summary"`
Storyline string `json:"storyline"`
// First release date
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
Rating float64 `json:"rating"`
RatingCount int `json:"ratingCount"`
Status int `json:"status"`
Category int `json:"category"`
// Arrays turned to strings that may be useful
GameModes string `json:"gameModes"`
Genres string `json:"genres"`
Platforms string `json:"platforms"`
// Id to poster image row (cached game cover)
PosterID *uint `json:"-"`
Poster *Image `json:"poster,omitempty"`
}
type PlayedAddRequest struct {
Status WatchedStatus `json:"status"`
Rating float64 `json:"rating" binding:"max=10"`
IgdbID int `json:"igdbId" binding:"required"`
}
// Cache(save) game to our table
func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
slog.Info("Saving game to db", "id", c.IgdbID, "name", c.Name)
if c.IgdbID == 0 || c.Name == "" {
slog.Error("saveGame: content missing id or name!", "id", c.IgdbID, "name", c.Name)
return errors.New("game missing id or title")
}
if c.CoverID != "" {
p, err := downloadAndInsertImage(db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
if err != nil {
slog.Error("saveGame: Failed to cache game cover.", "error", err)
} else {
slog.Debug("saveGame: Cached game cover", "p", p)
c.PosterID = &p.ID
}
}
var res *gorm.DB
if onlyUpdate {
// We only want to update an existing row, if it exists.
res = db.Model(&Game{}).Where("igdb_id = ?", c.IgdbID).Updates(c)
if res.Error != nil {
slog.Error("saveGame: Error updating game in database", "error", res.Error.Error())
return errors.New("failed to update cached game in database")
}
} else {
// On conflict, update existing row with details incase any were updated/missing.
res = db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "igdb_id"}},
DoUpdates: clause.AssignmentColumns([]string{
"name",
"cover_id",
"summary",
"storyline",
"release_date",
"rating",
"rating_count",
"status",
"game_modes",
"genres",
}),
}).Create(&c)
if res.Error != nil {
// Error if anything but unique contraint error
if res.Error != gorm.ErrDuplicatedKey {
slog.Error("saveGame: Error creating game in database", "error", res.Error.Error())
return errors.New("failed to cache game in database")
}
}
}
return nil
}
func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (Game, error) {
slog.Debug("cacheGame", "game_details", g)
var (
gameModes string
genres string
platforms string
)
if len(g.GameModes) > 0 {
for _, v := range g.GameModes {
gameModes += v.Name + "|"
}
}
if len(g.Genres) > 0 {
for _, v := range g.Genres {
genres += v.Name + "|"
}
}
if len(g.Platforms) > 0 {
for _, v := range g.Platforms {
platforms += v.Name + "|"
}
}
c := Game{
IgdbID: g.ID,
Name: g.Name,
CoverID: g.Cover.ImageID,
Summary: g.Summary,
Storyline: g.Storyline,
ReleaseDate: &g.FirstReleaseDate.Time,
Rating: (g.Rating),
RatingCount: g.RatingCount,
Status: g.Status,
Category: g.Category,
GameModes: gameModes,
Genres: genres,
Platforms: platforms,
}
err := saveGame(db, &c, onlyUpdate)
if err != nil {
slog.Error("cacheGame: Failed to save game!", "error", err)
return Game{}, errors.New("failed to save game")
}
return c, nil
}
// For adding/updating played games, we will reuse methods defined in watched.go where easily possible.
func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, at ActivityType) (Watched, error) {
slog.Debug("Adding played item", "userId", userId, "igdbId", ar.IgdbID)
var game Game
db.Where("igdb_id = ?", ar.IgdbID).Find(&game)
// Create game if not found from our db
if game == (Game{}) {
slog.Debug("Game not in db, fetching...")
resp, err := igdb.GameDetailsBasic(strconv.Itoa(ar.IgdbID))
if err != nil {
slog.Error("addPlayed content tmdb api request failed", "error", err)
return Watched{}, errors.New("failed to find requested games")
}
game, err = cacheGame(db, resp, false)
if err != nil {
slog.Error("addPlayed failed to cache game", "igdb_id", ar.IgdbID, "err", err)
return Watched{}, errors.New("failed to cache content")
}
}
// Error if content has no id
if game.ID == 0 {
return Watched{}, errors.New("failed to find game by id")
}
// Create watched entry in db
if ar.Status == "" {
ar.Status = FINISHED
}
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, GameID: &game.ID}
res := db.Create(&watched)
if res.Error != nil {
if res.Error == gorm.ErrDuplicatedKey {
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND game_id = ?", userId, watched.GameID).Take(&watched)
if res.Error != nil {
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
}
if watched.DeletedAt.Time.IsZero() {
return Watched{}, errors.New("content already on watched list")
} else {
slog.Info("addPlayed: Watched list item for this content exists as soft deleted record.. attempting to restore")
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND game_id = ?", userId, watched.GameID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
watched.Status = ar.Status
watched.Rating = ar.Rating
if res.Error != nil {
slog.Error("addPlayed: Failed to restore soft deleted watch list item", "error", res.Error)
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
}
}
} else {
slog.Error("Error adding watched content to database", "error", res.Error.Error())
return Watched{}, errors.New("failed adding content to database")
}
}
slog.Debug("Added watched list item", "item", watched)
var activity Activity
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
if err != nil {
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
} else {
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
}
watched.Activity = append(watched.Activity, activity)
watched.Game = &game
return watched, nil
}
+30 -24
View File
@@ -1,50 +1,56 @@
module github.com/sbondCo/Watcharr
go 1.21
go 1.22
require (
github.com/gin-contrib/cache v1.2.0
github.com/gin-contrib/cors v1.4.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.1.0
github.com/buckket/go-blurhash v1.1.0
github.com/gin-contrib/cache v1.3.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.11.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.15.0
golang.org/x/crypto v0.27.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12
)
require (
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bytedance/sonic v1.10.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.15.5 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.4.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/memcachier/mc/v3 v3.0.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+65 -90
View File
@@ -1,82 +1,73 @@
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.1.0 h1:UGKbA/IPjtS6zLcdB7i5TyACMgSbOTiR8qzXgw8HWQU=
github.com/golang-jwt/jwt/v5 v5.1.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -84,78 +75,62 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.15.0 h1:frVn1TEaCEaZcn3Tmd7Y2b5KKPaZ+I32Q2OA3kYp5TA=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"log/slog"
"mime/multipart"
"net/http"
"os"
"path"
"path/filepath"
"time"
"github.com/buckket/go-blurhash"
"gorm.io/gorm"
)
// For user uploaded images
type Image struct {
ID uint `gorm:"primarykey" json:"-"`
CreatedAt time.Time `json:"createdAt"`
Hash string `gorm:"uniqueIndex;not null" json:"-"`
BlurHash string `json:"blurHash"`
// Path constructable from hash alone, but I can't decide
// if I should have this or not so I figure it's easier
// to remove it later than to add it later....... -_-
Path string `gorm:"not null" json:"path"`
}
// Insert an image into database
func insertImage(db *gorm.DB, hash string, path string, f io.Reader) (Image, error) {
bh, _ := getBlurHash(f)
img := Image{
Hash: hash,
Path: path,
BlurHash: bh,
}
r := db.Where(Image{Hash: hash}).FirstOrCreate(&img)
if r.Error != nil {
slog.Error("insertImage firstOrCreate failed!", "error", r.Error)
return Image{}, errors.New("failed to select or create image")
}
return img, nil
}
func getBlurHash(img io.Reader) (string, error) {
i, _, err := image.Decode(img)
if err != nil {
// Handle errors
slog.Error("getBlurHash decoding image failed", "error", err)
return "", errors.New("decoding image failed")
}
bh, _ := blurhash.Encode(6, 5, i)
if err != nil {
// Handle errors
slog.Error("getBlurHash generation failed", "error", err)
return "", errors.New("blur hash generation failed")
}
slog.Debug("getBlurHash", "hash", bh)
return bh, nil
}
func cleanupImages(db *gorm.DB) {
slog.Info("cleanupImages running")
var unusedImgs []Image
// Select images that are not referenced by at least one other row.
// Currently only used for user avatars, add new tables when used.
db.Raw(`SELECT *
FROM images
WHERE NOT EXISTS (
SELECT 1
FROM users
WHERE users.avatar_id = images.id
);`).Scan(&unusedImgs)
slog.Info("cleanupImages: scanned for unused images", "amount", len(unusedImgs))
if len(unusedImgs) > 0 {
for _, v := range unusedImgs {
slog.Debug("cleanupImages: removing an image", "id", v.ID, "path", v.Path)
err := db.Transaction(func(tx *gorm.DB) error {
// Try to delete image from db
if err := tx.Where("id = ?", v.ID).Delete(&Image{}).Error; err != nil {
return err
}
// hope its ok to do this sorta thing here :skull:
if err := os.Remove(path.Join(DataPath, v.Path)); err != nil {
return err
}
// commit transaction if no errors
return nil
})
if err != nil {
slog.Error("cleanupImages: failed to remove image - db row and file kept", "img", v, "error", err)
} else {
slog.Debug("cleanupImages: successfully removed unused image.", "id", v.ID)
}
}
}
}
func isValidImageType(f multipart.File) error {
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
// Reading whole file is a waste.
buff := make([]byte, 512)
if _, err := f.Read(buff); err != nil {
slog.Error("isValidImageType: failed to read file into buffer", "error", err)
return errors.New("failed to verify if image is valid")
}
t := http.DetectContentType(buff)
slog.Debug("isValidImageType", "type", t)
if t != "image/png" && t != "image/jpeg" && t != "image/webp" && t != "image/gif" {
slog.Debug("isValidImageType: rejecting file as not valid (supported) image type")
return errors.New("invalid file type")
}
return nil
}
func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image, error) {
slog.Debug("Attempting to download image", "url", url)
// Get the data
resp, err := http.Get(url)
if err != nil {
return Image{}, err
}
defer resp.Body.Close()
// Check server response
if resp.StatusCode != http.StatusOK {
return Image{}, fmt.Errorf("bad status: %s", resp.Status)
}
// Read body into byte array, then create new reader
// So we have the ability to seek.
b, err := io.ReadAll(resp.Body)
if err != nil {
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
return Image{}, err
}
br := bytes.NewReader(b)
h := sha256.New()
if _, err := io.Copy(h, br); err != nil {
log.Fatal(err) // TODO nu le fatal
}
hs := hex.EncodeToString(h.Sum(nil))
// Seek back for file
_, err = br.Seek(0, 0)
if err != nil {
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
return Image{}, err
}
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
dataOutP := path.Join(DataPath, outp)
// Create the file
out, err := os.Create(dataOutP)
if err != nil {
if os.IsNotExist(err) {
err = os.MkdirAll(path.Dir(dataOutP), 0764)
if err != nil {
return Image{}, err
}
// If dirs made, try making file again
out, err = os.Create(dataOutP)
if err != nil {
return Image{}, err
}
} else {
return Image{}, err
}
}
defer out.Close()
_, err = io.Copy(out, br)
if err != nil {
return Image{}, err
}
// Seek back for insertImage
_, err = br.Seek(0, 0)
if err != nil {
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
return Image{}, err
}
img, err := insertImage(db, hs, outp, br)
if err != nil {
return Image{}, err
}
return img, nil
}
+132 -9
View File
@@ -27,11 +27,18 @@ var (
)
type ImportRequest struct {
Name string `json:"name"`
TmdbID int `json:"tmdbId"`
Type ContentType `json:"type"`
Rating int8 `json:"rating"`
RatingCustomDate *time.Time `json:"ratingCustomDate"`
Name string `json:"name"`
TmdbID int `json:"tmdbId"`
Type ContentType `json:"type"`
Rating float64 `json:"rating" binding:"max=10"`
RatingCustomDate *time.Time `json:"ratingCustomDate"`
Status WatchedStatus `json:"status"`
Thoughts string `json:"thoughts"`
DatesWatched []time.Time `json:"datesWatched"`
Activity []Activity `json:"activity"`
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
Tags []TagAddRequest `json:"tags"`
}
type ImportResponse struct {
@@ -49,14 +56,14 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
if ar.TmdbID != 0 && (ar.Type == MOVIE || ar.Type == SHOW) {
tid := strconv.Itoa(ar.TmdbID)
if ar.Type == MOVIE {
cr, err := movieDetails(tid, "", map[string]string{})
cr, err := movieDetails(db, tid, "", map[string]string{})
if err != nil {
return ImportResponse{}, errors.New("movie details request failed")
}
slog.Debug("import: by tmdbid of movie", "cr", cr)
return successfulImport(db, userId, cr.ID, MOVIE, ar)
} else if ar.Type == SHOW {
cr, err := tvDetails(tid, "", map[string]string{})
cr, err := tvDetails(db, tid, "", map[string]string{})
if err != nil {
return ImportResponse{}, errors.New("tv details request failed")
}
@@ -64,7 +71,8 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
return successfulImport(db, userId, cr.ID, SHOW, ar)
}
}
sr, err := searchContent(ar.Name)
// tmdbId not passed.. search for the content by name.
sr, err := searchContent(ar.Name, 1)
if err != nil {
slog.Error("import: content search failed", "error", err)
return ImportResponse{}, errors.New("Content search failed")
@@ -115,11 +123,26 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
}
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType, ar ImportRequest) (ImportResponse, error) {
status := FINISHED
if ar.Status != "" {
status = ar.Status
}
// Get the latest date from DatesWatched if we have any.
var wDate time.Time
if len(ar.DatesWatched) > 0 {
for _, dw := range ar.DatesWatched {
if dw.After(wDate) {
wDate = dw
}
}
}
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
Status: status,
ContentID: contentId,
ContentType: contentType,
Rating: ar.Rating,
Thoughts: ar.Thoughts,
WatchedDate: wDate,
}, IMPORTED_WATCHED)
if err != nil {
if err.Error() == "content already on watched list" {
@@ -140,5 +163,105 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
}
w.Activity = append(w.Activity, addedActivity)
}
// Add all dates watched as activity, if any
if len(ar.DatesWatched) > 0 {
for _, v := range ar.DatesWatched {
customDate := v
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
if err == nil {
w.Activity = append(w.Activity, addedActivity)
} else {
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
}
}
}
// Add all activity passed in.
// Probably was is a Watcharr export being imported, so it'll have all it's activity too.
if len(ar.Activity) > 0 {
slog.Debug("successfulImport: Importing activity")
for i, v := range ar.Activity {
activityDate := ar.Activity[i].CustomDate
if activityDate == nil || activityDate.IsZero() {
activityDate = &ar.Activity[i].CreatedAt
}
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
if err == nil {
w.Activity = append(w.Activity, addedActivity)
} else {
slog.Error("successfulImport: Failed to add imported activity.", "full_object", v, "error", err)
}
}
}
// Import watched seasons, if any
if len(ar.WatchedSeason) > 0 {
slog.Debug("successfulImport: Importing watched seasons")
for _, v := range ar.WatchedSeason {
ws, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
WatchedID: w.ID,
SeasonNumber: v.SeasonNumber,
Status: v.Status,
Rating: v.Rating,
addActivityDate: v.CreatedAt,
})
if err != nil {
slog.Error("successfulImport: Failed to add watched season.", "error", err)
continue
}
w.WatchedSeasons = ws.WatchedSeasons
}
}
// Import watched episodes, if any
if len(ar.WatchedEpisodes) > 0 {
slog.Debug("successfulImport: Importing watched episodes")
for _, v := range ar.WatchedEpisodes {
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: v.SeasonNumber,
EpisodeNumber: v.EpisodeNumber,
Status: v.Status,
Rating: v.Rating,
addActivityDate: v.CreatedAt,
})
if err != nil {
slog.Error("successfulImport: Failed to add watched episodes.", "error", err)
continue
}
w.WatchedEpisodes = ws.WatchedEpisodes
}
}
// Import tags, if any
if len(ar.Tags) > 0 {
// Create tags if they dont exist
slog.Debug("successfulImport: Importing tags")
for _, v := range ar.Tags {
// Check if tag exists
var t Tag
t, err := getTagByNameAndColor(db, userId, v.Name, v.Color, v.BgColor)
if err != nil && err.Error() != "tag does not exist" {
slog.Error("successfulImport: Failed to check for an existing tag", "name", v.Name, "error", err)
continue
}
if t.ID == 0 {
tag, err := addTag(db, userId, TagAddRequest{
Name: v.Name,
Color: v.Color,
BgColor: v.BgColor,
})
if err != nil {
slog.Error("successfulImport: Failed to add a tag.", "name", v.Name, "error", err)
continue
}
t = tag
}
// Associate the watched entry with the tag
err = addWatchedToTag(db, userId, t.ID, w.ID)
if err != nil {
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
continue
}
w.Tags = append(w.Tags, t)
}
}
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
}
+456
View File
@@ -0,0 +1,456 @@
// Trakt.tv importer.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strconv"
"time"
"gorm.io/gorm"
)
type TraktImportRequest struct {
// Username of public trakt user to import from.
Username string `json:"username" binding:"required"`
}
type TraktUser struct {
Username string `json:"username"`
Private bool `json:"private"`
IDs struct {
Slug string `json:"slug"`
} `json:"ids"`
}
type TraktHistory struct {
ID int64 `json:"id"`
WatchedAt time.Time `json:"watched_at"`
Action string `json:"action"`
Type string `json:"type"`
Show TraktListShow `json:"show,omitempty"`
Episode TraktListEpisode `json:"episode,omitempty"`
Movie TraktListMovie `json:"movie,omitempty"`
}
type TraktWatchlist []struct {
Rank int `json:"rank"`
ID int `json:"id"`
ListedAt time.Time `json:"listed_at"`
Notes string `json:"notes"`
Type string `json:"type"`
Show TraktListShow `json:"show,omitempty"`
Episode TraktListEpisode `json:"episode,omitempty"`
Movie TraktListMovie `json:"movie,omitempty"`
}
type TraktRatings []struct {
Rating int `json:"rating"`
Type string `json:"type"`
Show TraktListShow `json:"show,omitempty"`
Episode TraktListEpisode `json:"episode,omitempty"`
Movie TraktListMovie `json:"movie,omitempty"`
}
type TraktListShow struct {
Title string `json:"title"`
Year int `json:"year"`
Ids struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
Tmdb int `json:"tmdb"`
} `json:"ids"`
}
type TraktListEpisode struct {
Season int `json:"season"`
Number int `json:"number"`
Title string `json:"title"`
Ids struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
Tmdb int `json:"tmdb"`
} `json:"ids"`
}
type TraktListMovie struct {
Title string `json:"title"`
Year int `json:"year"`
Ids struct {
Trakt int `json:"trakt"`
Slug string `json:"slug"`
Tmdb int `json:"tmdb"`
} `json:"ids"`
}
type TraktImportResponse struct {
JobId string `json:"jobId"`
}
// TODO we could support trakt list imports when we support a similar feature (tags will function as custom lists when done #199)
func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername string) {
// Get trakt user. We want to get their profile `slug` for use in
// next requests and we can check their profile isn't private while here.
var traktUser TraktUser
_, err := traktAPIRequest("users/"+traktUsername, map[string]string{}, &traktUser)
if err != nil {
slog.Error("startTraktImport: Failed to get users profile", "error", err, "trakt_user", traktUser)
addJobError(jobId, userId, "failed to request trakt profile from api")
updateJobStatus(jobId, userId, JOB_CANCELLED)
return
}
if traktUser.Private {
slog.Error("startTraktImport: Users profile is private. Cannot continue with import.")
addJobError(jobId, userId, "trakt profile is private")
updateJobStatus(jobId, userId, JOB_CANCELLED)
return
}
userSlug := traktUser.IDs.Slug
// Everything will be added to this map for importing at the end.
toImport := map[string]ImportRequest{}
// Process all history for this user (in chunks of 1000).
var history []TraktHistory
slog.Debug("startTraktImport: Getting first history page")
historyHeaders, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000"}, &history)
if err != nil {
// FATAL if we can't get the users history, we probably shouldn't continue (to ratings/watchlist below).
slog.Error("startTraktImport: Failed to get users history", "error", err)
addJobError(jobId, userId, "failed to get your history")
return
} else {
pageCount := historyHeaders.Get("x-pagination-page-count")
slog.Debug("startTraktImport: Got first history page", "page_count", pageCount)
if pageCount == "" {
slog.Error("startTraktImport: Failed to get history page count!", "page_count", pageCount)
addJobError(jobId, userId, "Failed to get history page count")
return
}
pageCountNum, err := strconv.Atoi(pageCount)
if err != nil {
slog.Error("startTraktImport: Failed to parse history page count into an int!", "error", err)
addJobError(jobId, userId, "Failed to parse history page count: "+pageCount)
return
}
rProc := func(v TraktHistory) {
var collectingText string
if v.Type == "episode" {
collectingText = fmt.Sprintf("%s S%dE%d", v.Show.Title, v.Episode.Season, v.Episode.Number)
} else if v.Type == "show" {
collectingText = v.Show.Title
} else if v.Type == "movie" {
collectingText = v.Movie.Title
}
if collectingText != "" {
updateJobCurrentTask(jobId, userId, "collecting "+collectingText)
}
err = processTraktHistoryItem(v, toImport)
if err != nil {
addJobError(jobId, userId, err.Error())
}
}
// Process first page of history (next pages processed below)
for _, v := range history {
rProc(v)
}
for i := range pageCountNum {
slog.Debug("startTraktImport: Getting history page", "page_num", i)
_, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000", "page": strconv.Itoa(i)}, &history)
if err != nil {
slog.Error("startTraktImport: Failed to get a history page", "page_num", i, "error", err)
addJobError(jobId, userId, "Failed to get history page: "+strconv.Itoa(i))
} else {
for _, v := range history {
rProc(v)
}
}
}
slog.Info("startTraktImport: Finished processing all history")
history = nil // clear whatever is lingering in the history slice
}
// Get watchlist for PLANNED items
slog.Info("startTraktImport: Getting whole watchlist")
var watchlist TraktWatchlist
_, err = traktAPIRequest("users/"+userSlug+"/watchlist", map[string]string{}, &watchlist)
if err != nil {
slog.Error("startTraktImport: Failed to get users watchlist! Cannot import planned content.", "error", err)
addJobError(jobId, userId, "failed to get your watchlist (planned items cannot be imported)")
} else {
slog.Debug("startTraktImport: Successfully got whole watchlist")
for _, v := range watchlist {
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
var (
title string
contentType ContentType
tmdbId int
)
if v.Type == "show" || v.Type == "episode" {
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
contentType = SHOW
if v.Type == "episode" {
title = v.Episode.Title
}
} else if v.Type == "movie" {
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = MOVIE
}
updateJobCurrentTask(jobId, userId, "setting status for "+title)
mapKey := makeTraktMapKey(contentType, tmdbId)
if mv, ok := toImport[mapKey]; ok {
// If item already exists in toImport, set its status to planned.
if v.Type == "episode" {
// For episode entries, we have to find the WatchedEpisode to set its status to planned.
weFound := false
for i, we := range mv.WatchedEpisodes {
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
we.Status = PLANNED
mv.WatchedEpisodes[i] = we
weFound = true
break
}
}
if !weFound {
mv.WatchedEpisodes = append(mv.WatchedEpisodes, WatchedEpisode{
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: PLANNED,
GormModel: GormModel{
CreatedAt: v.ListedAt,
},
})
}
toImport[mapKey] = mv
} else {
mv.Status = PLANNED
if v.Notes != "" {
// episodes dont support notes in watcharr
mv.Thoughts = v.Notes
}
toImport[mapKey] = mv
}
} else {
// If the item does not exist in toImport, create it and set it to planned.
ti := ImportRequest{
Type: contentType,
TmdbID: tmdbId,
Status: PLANNED,
}
if v.Type == "episode" {
ti.WatchedEpisodes = []WatchedEpisode{{
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: PLANNED,
GormModel: GormModel{
CreatedAt: v.ListedAt,
},
}}
} else {
// episodes dont support notes in watcharr
ti.Thoughts = v.Notes
}
toImport[mapKey] = ti
}
}
}
// Process ratings
slog.Info("startTraktImport: Getting all ratings")
var ratings TraktRatings
_, err = traktAPIRequest("users/"+userSlug+"/ratings", map[string]string{}, &ratings)
if err != nil {
slog.Error("startTraktImport: Failed to get users ratings!", "error", err)
addJobError(jobId, userId, "failed to get your ratings (content ratings cannot be imported)")
} else {
slog.Debug("startTraktImport: Successfully got all ratings")
for _, v := range ratings {
slog.Debug("startTraktImport: Processing rating item", "item", v)
var (
title string
contentType ContentType
tmdbId int
traktSlug string
)
if v.Type == "show" || v.Type == "episode" {
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
traktSlug = v.Show.Ids.Slug
contentType = SHOW
if v.Type == "episode" {
title = v.Episode.Title
traktSlug = v.Episode.Ids.Slug
}
} else if v.Type == "movie" {
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = MOVIE
traktSlug = v.Movie.Ids.Slug
}
updateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
mapKey := makeTraktMapKey(contentType, tmdbId)
if mv, ok := toImport[mapKey]; ok {
if v.Type == "episode" {
// For episode entries, we have to find the WatchedEpisode to set its rating.
epFound := false
for i, we := range mv.WatchedEpisodes {
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
we.Rating = int8(v.Rating)
mv.WatchedEpisodes[i] = we
epFound = true
break
}
}
toImport[mapKey] = mv
if !epFound {
addJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
}
} else {
mv.Rating = float64(v.Rating)
toImport[mapKey] = mv
}
} else {
// Item should be in toImport by now (from history or watchlist) if it has a rating, otherwise we won't import it
addJobError(jobId, userId, fmt.Sprintf("cannot import rating of %d for %s. The main content does not exist in your history or watchlist. type: %s traktSlug: %s", v.Rating, title, v.Type, traktSlug))
}
}
}
// Loop over `toImport` and finally import everything.
for _, v := range toImport {
_, err := importContent(db, userId, v)
if err != nil {
slog.Error("startTraktImport: Failed to do import on content!", "error", err, "import_obj", v)
addJobError(jobId, userId, fmt.Sprintf("Failed to import %s as %s. tmdbId: %d", v.Type, v.Status, v.TmdbID))
}
}
// We are donezo
updateJobStatus(jobId, userId, JOB_DONE)
}
func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest) error {
var (
title string
traktId int
tmdbId int
contentType ContentType
watchedEpisode WatchedEpisode
)
if v.Type == "show" || v.Type == "episode" {
title = v.Show.Title
traktId = v.Show.Ids.Trakt
tmdbId = v.Show.Ids.Tmdb
contentType = SHOW
if v.Type == "episode" {
traktId = v.Episode.Ids.Trakt
watchedEpisode = WatchedEpisode{
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: FINISHED,
// Rating: ,
GormModel: GormModel{
CreatedAt: v.WatchedAt,
},
}
slog.Debug("processTraktHistoryItem: Processing an episode.", "showTitle", title, "season", v.Episode.Season, "episode", v.Episode.Number)
} else {
slog.Debug("processTraktHistoryItem: Processing a show.", "contentTitle", title, "contentTmdbId", tmdbId)
}
} else if v.Type == "movie" {
title = v.Movie.Title
traktId = v.Movie.Ids.Trakt
tmdbId = v.Movie.Ids.Tmdb
contentType = MOVIE
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
}
if tmdbId == 0 {
slog.Debug("processTraktHistoryItem: Item had no tmdbId. Cannot process.")
return errors.New("Failed to process history: " + title + " type:" + v.Type + " trakt id:" + strconv.Itoa(traktId) + " tmdb id:" + strconv.Itoa(tmdbId) + " error:" + "item had no tmdb id")
}
mapKey := makeTraktMapKey(contentType, tmdbId)
if e, ok := toImport[mapKey]; ok {
e.WatchedEpisodes = append(toImport[mapKey].WatchedEpisodes, watchedEpisode)
toImport[mapKey] = e
} else {
toImport[mapKey] = ImportRequest{
Type: contentType,
TmdbID: tmdbId,
Status: FINISHED,
DatesWatched: []time.Time{v.WatchedAt},
WatchedEpisodes: []WatchedEpisode{watchedEpisode},
}
}
return nil
}
// `tmdbId` is for the movie or show (not for episodes).
func makeTraktMapKey(ct ContentType, tmdbId int) string {
return string(ct) + strconv.Itoa(tmdbId)
}
func traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Header, error) {
base, err := url.Parse("https://api.trakt.tv")
if err != nil {
return map[string][]string{}, errors.New("failed to parse api uri")
}
base.Path += ep
if len(p) > 0 {
params := url.Values{}
for k, v := range p {
params.Add(k, v)
}
base.RawQuery = params.Encode()
}
slog.Debug("traktAPIRequest", "request_url", base.String())
req, err := http.NewRequest("GET", base.String(), nil)
if err != nil {
return map[string][]string{}, err
}
req.Header.Add("trakt-api-key", "c481cb044dcd58d83f3fde113741d1e28d19c1bef1bcbfcb9acedee222f3a673")
req.Header.Add("trakt-api-version", "2")
req.Header.Add("Content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return map[string][]string{}, err
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
return map[string][]string{}, err
}
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
slog.Error("traktAPIRequest: non 2xx status code:", "status_code", res.StatusCode)
return map[string][]string{}, errors.New("non success status code")
}
err = json.Unmarshal([]byte(body), &resp)
if err != nil {
return map[string][]string{}, err
}
return res.Header, nil
}
func traktImportWatched(
db *gorm.DB,
userId uint,
traktUsername string,
) (TraktImportResponse, error) {
jobId, err := addUniqueJob("trakt_import", userId)
if err != nil {
slog.Error("traktSyncWatched: Failed to create a job", "error", err)
return TraktImportResponse{}, err
}
updateJobStatus(jobId, userId, JOB_RUNNING)
go startTraktImport(
db,
jobId,
userId,
traktUsername,
)
return TraktImportResponse{JobId: jobId}, nil
}
+45 -12
View File
@@ -8,6 +8,9 @@ import (
"log/slog"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
type JellyfinItemSearchResponse struct {
@@ -15,12 +18,27 @@ type JellyfinItemSearchResponse struct {
}
type JellyfinItems struct {
Name string `json:"Name"`
Type string `json:"Type"`
ServerID string `json:"ServerId"`
Id string `json:"Id"`
ProviderIds struct {
Tmdb string `json:"Tmdb"`
} `json:"ProviderIds"`
UserData struct {
Rating float64 `json:"Rating"`
PlayedPercentage float64 `json:"PlayedPercentage"`
UnplayedItemCount int64 `json:"UnplayedItemCount"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayCount int64 `json:"PlayCount"`
IsFavorite bool `json:"IsFavorite"`
Likes bool `json:"Likes"`
LastPlayedDate time.Time `json:"LastPlayedDate"`
Played bool `json:"Played"`
Key string `json:"Key"`
ItemId string `json:"ItemId"`
} `json:"UserData"`
RecursiveItemCount int64 `json:"RecursiveItemCount"`
}
type JFContentFindResponse struct {
@@ -28,6 +46,33 @@ type JFContentFindResponse struct {
Url string `json:"url"`
}
// Jellyfin access middleware, ensures user is a jellyfin user.
// To be ran after AuthRequired middleware with extra data.
func JellyfinAccessRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
slog.Debug("JellyfinAccessRequired middleware hit", "user_id", userId)
userType := c.MustGet("userType").(UserType)
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
if Config.JELLYFIN_HOST == "" {
slog.Error("JellyfinAccessRequired: Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
c.AbortWithStatus(401)
return
}
if userType != JELLYFIN_USER || userThirdPartyId == "" {
slog.Error("JellyfinAccessRequired: User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
c.AbortWithStatus(401)
return
}
if userThirdPartyAuth == "" {
slog.Error("JellyfinAccessRequired: User has no thirdPartyAuth token..")
c.AbortWithStatus(401)
return
}
}
}
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
if Config.JELLYFIN_HOST == "" {
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
@@ -97,18 +142,6 @@ func jellyfinContentFind(
contentName string,
contentTmdbId string,
) (JFContentFindResponse, error) {
if Config.JELLYFIN_HOST == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
}
if userType != JELLYFIN_USER || userThirdPartyId == "" {
slog.Error("User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
return JFContentFindResponse{}, errors.New("not jellyfin user")
}
if userThirdPartyAuth == "" {
slog.Error("User has no thirdPartyAuth token..")
return JFContentFindResponse{}, errors.New("user has no jellyfin auth token")
}
if contentType == "" || contentName == "" {
slog.Error("Bad request", "content_type", contentType, "content_name", contentName)
return JFContentFindResponse{}, errors.New("content type or name not provided")
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"errors"
"log/slog"
"strconv"
"gorm.io/gorm"
)
type JellyfinSeriesSeasonsResponse struct {
Items []JellyfinSeriesSeasonItem `json:"Items"`
}
type JellyfinSeriesSeasonItem struct {
JellyfinItems
// aka the season number
IndexNumber int `json:"IndexNumber"`
}
type JellyfinSeriesEpisodesResponse struct {
Items []JellyfinSeriesEpisodeItem `json:"Items"`
}
type JellyfinSeriesEpisodeItem struct {
JellyfinItems
// the episode number
IndexNumber int `json:"IndexNumber"`
// the episodes season number
ParentIndexNumber int `json:"ParentIndexNumber"`
}
type JellyfinSyncResponse struct {
JobId string `json:"jobId"`
}
// Perform the jellyfin sync.
// Gets each type of media separately from jellyfin and attempts to import them.
// Errors are added silently to the job.
func startJellyfinSync(
db *gorm.DB,
jobId string,
userId uint,
username string,
userThirdPartyId string,
userThirdPartyAuth string,
) {
// Get played movies
updateJobCurrentTask(jobId, userId, "syncing movies")
playedMovies := new(JellyfinItemSearchResponse)
err := jellyfinAPIRequest(
"GET",
"/Users/"+userThirdPartyId+"/Items",
map[string]string{
"Filters": "IsPlayed",
"IncludeItemTypes": "Movie",
"Fields": "ProviderIds",
"Recursive": "true",
},
username,
userThirdPartyAuth,
&playedMovies,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
addJobError(jobId, userId, "failed to get jellyfin response for movies")
} else {
if len(playedMovies.Items) <= 0 {
slog.Info("jellyfinSyncWatched: User has no played movies.", "user_id", userId)
} else {
for _, v := range playedMovies.Items {
slog.Info("jellyfinSyncWatched: Importing played movie.", "movie_name", v.Name, "user_id", userId)
slog.Debug("jellyfinSyncWatched: Importing played movie.", "full_item", v, "user_id", userId)
// 1. Ensure we have a tmdbId
if v.ProviderIds.Tmdb == "" {
slog.Error("jellyfinSyncWatched: Movie to import does not have a tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+v.Name)
continue
}
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
if err != nil {
slog.Error("jellyfinSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+v.Name)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+v.Name)
// 2. Imported watched movie
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: MOVIE,
WatchedDate: v.UserData.LastPlayedDate,
}, IMPORTED_WATCHED_JF)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
} else {
slog.Error("jellyfinSyncWatched: Movie failed to import.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (failed when adding to watched list): "+v.Name)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
if !v.UserData.LastPlayedDate.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
}
}
}
}
}
}
// Get played series
// Can't rely on IsPlayed filter, since we want to get partially played series too.
updateJobCurrentTask(jobId, userId, "syncing series")
allSeries := new(JellyfinItemSearchResponse)
err = jellyfinAPIRequest(
"GET",
"/Users/"+userThirdPartyId+"/Items",
map[string]string{
"IncludeItemTypes": "Series",
"Fields": "ProviderIds,RecursiveItemCount",
"Recursive": "true",
"IsPlaceHolder": "false",
},
username,
userThirdPartyAuth,
&allSeries,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
addJobError(jobId, userId, "failed to get jellyfin response for series")
} else {
if len(allSeries.Items) <= 0 {
slog.Info("jellyfinSyncWatched: No series found.", "user_id", userId)
} else {
// Import series
for _, v := range allSeries.Items {
slog.Info("jellyfinSyncWatched: Processing series.", "series_name", v.Name, "user_id", userId)
slog.Debug("jellyfinSyncWatched: Processing series.", "full_item", v, "user_id", userId)
// 1. Make sure show is watched or at least partially watched
if !v.UserData.Played && v.UserData.PlayedPercentage <= 0 && v.RecursiveItemCount == v.UserData.UnplayedItemCount {
slog.Debug("jellyfinSyncWatched: Skipping unwatched series:", "series_name", v.Name, "user_id", userId)
continue
}
// 1.1. Ensure we have a tmdbId
if v.ProviderIds.Tmdb == "" {
slog.Error("jellyfinSyncWatched: Series to import does not have a tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "series could not be imported (no tmdbId present): "+v.Name)
continue
}
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
if err != nil {
slog.Error("jellyfinSyncWatched: Series to import does not have a parseable (to int) tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "series could not be imported (tmdbId was not parseable): "+v.Name)
continue
}
updateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
// 2. Imported watched series
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: SHOW,
WatchedDate: v.UserData.LastPlayedDate,
}, IMPORTED_WATCHED_JF)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Info("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.",
"series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "watched_id", w.ID)
} else {
slog.Error("jellyfinSyncWatched: Series failed to import.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "series could not be imported (failed when adding to watched list): "+v.Name)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
if !v.UserData.LastPlayedDate.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
}
}
}
// 4. Import watched seasons for this serie
// Get all show seasons (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
seriesSeasons := new(JellyfinSeriesSeasonsResponse)
err = jellyfinAPIRequest(
"GET",
"/Shows/"+v.Id+"/Seasons",
map[string]string{
"UserId": userThirdPartyId,
"Fields": "ProviderIds",
"IsPlaceHolder": "false",
},
username,
userThirdPartyAuth,
&seriesSeasons,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
} else if len(seriesSeasons.Items) <= 0 {
slog.Info("jellyfinSyncWatched: Series has no seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
} else {
for _, vs := range seriesSeasons.Items {
slog.Debug("jellyfinSyncWatched: Processing a season.", "full_item", v, "user_id", userId)
if !vs.UserData.Played {
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed season.", "series_name", v.Name, "season_num", vs.IndexNumber, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.IndexNumber,
Status: FINISHED,
addActivity: SEASON_ADDED_JF,
addActivityDate: vs.UserData.LastPlayedDate,
})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
}
}
}
// 5. Import watched episodes for this serie
// Gets all show episodes (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
seriesEpisodes := new(JellyfinSeriesEpisodesResponse)
err = jellyfinAPIRequest(
"GET",
"/Shows/"+v.Id+"/Episodes",
map[string]string{
"UserId": userThirdPartyId,
"Fields": "ProviderIds",
"IsPlaceHolder": "false",
},
username,
userThirdPartyAuth,
&seriesEpisodes,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
} else if len(seriesEpisodes.Items) <= 0 {
slog.Info("jellyfinSyncWatched: Series has no episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
} else {
for _, vs := range seriesEpisodes.Items {
slog.Debug("jellyfinSyncWatched: Processing an episode.", "full_item", v, "user_id", userId)
if !vs.UserData.Played {
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.ParentIndexNumber)+" episode "+strconv.Itoa(vs.IndexNumber))
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.ParentIndexNumber,
EpisodeNumber: vs.IndexNumber,
Status: FINISHED,
addActivity: EPISODE_ADDED_JF,
addActivityDate: vs.UserData.LastPlayedDate,
})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
}
}
}
}
}
}
updateJobStatus(jobId, userId, JOB_DONE)
}
func jellyfinSyncWatched(
db *gorm.DB,
userId uint,
userType UserType,
username string,
userThirdPartyId string,
userThirdPartyAuth string,
) (JellyfinSyncResponse, error) {
jobId, err := addJob("jf_sync", userId)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to create a job", "error", err)
return JellyfinSyncResponse{}, errors.New("failed to create job")
}
updateJobStatus(jobId, userId, JOB_RUNNING)
go startJellyfinSync(
db,
jobId,
userId,
username,
userThirdPartyId,
userThirdPartyAuth,
)
return JellyfinSyncResponse{JobId: jobId}, nil
}
+143
View File
@@ -0,0 +1,143 @@
// We will use jobs only for storing/retrieving active job statuses for the client.
// Running the job will be done wherever needed, but isn't handled here.
// When starting a job elsewhere, we should first add a job here as active to get an `id`,
// this id should be used to update the active job so the client can request job status updates.
package main
import (
"errors"
"log/slog"
"time"
)
type JobStatus string
var (
JOB_CREATED JobStatus = "CREATED"
JOB_RUNNING JobStatus = "RUNNING"
JOB_DONE JobStatus = "DONE"
JOB_CANCELLED JobStatus = "CANCELLED"
)
type Job struct {
// We can give the job a name simply for showing on the client.
Name string `json:"name"`
// The current status of the job.
Status JobStatus `json:"status"`
// The current task we are performing inside the job.
// Just so we can portray progress on the client by displaying the current task.
CurrentTask string `json:"currentTask,omitempty"`
// Errors that occurred in the task
Errors []string `json:"errors"`
// Stored for access control.
UserId uint `json:"-"`
}
var activeJobs = make(map[string]*Job)
// Add a job to our activeJobs map.
// Returns id of job on success, or error if failed to add.
// Only return safe errors for display to users, log serious errors.
func addJob(name string, userId uint) (string, error) {
idk, err := generateString(8)
if err != nil {
slog.Error("addJob: Failed to generate a job id!", "error", err)
return "", errors.New("failed to generate a job id, please try again")
}
_, ok := activeJobs[idk]
if ok {
// Lets just hope this doesn't happen, may the odds be with us.
return "", errors.New("job already exists with id generated, please try again")
}
activeJobs[idk] = &Job{
Name: name,
Status: JOB_CREATED,
UserId: userId,
}
return idk, nil
}
// Add a job, but only if one with the same `name` isn't already running.
// Only return safe errors for display to users.
func addUniqueJob(name string, userId uint) (string, error) {
found := false
for _, v := range activeJobs {
if v.UserId == userId && v.Name == name && (v.Status == JOB_CREATED || v.Status == JOB_RUNNING) {
found = true
break
}
}
if found {
return "", errors.New("a job of this type is already running, please wait for the existing job to finish")
}
return addJob(name, userId)
}
func rmJob(id string, userId uint) {
slog.Debug("rmJob: Removing a job.", "id", id)
v, ok := activeJobs[id]
if ok && v.UserId == userId {
delete(activeJobs, id)
slog.Debug("rmJob: Removed a job.", "id", id)
return
}
slog.Debug("rmJob: Job to remove does not exist (or not owned by this user).", "id", id, "user_id", userId)
}
// Get a job.
// Returns job if found, otherwise errors if job does not exist.
func getJob(id string, userId uint) (*Job, error) {
j, ok := activeJobs[id]
if ok {
// Ensure user requesting a job, owns the job.
if j.UserId != userId {
slog.Warn("getJob: A user tried to access a job they do not own.", "user_id", userId, "job_id", id)
return &Job{}, errors.New("job does not exist")
}
return j, nil
}
return &Job{}, errors.New("job does not exist")
}
// Update a jobs status.
func updateJobStatus(id string, userId uint, status JobStatus) error {
j, err := getJob(id, userId)
if err != nil {
slog.Error("updateJobStatus: Failed!", "status", status, "error", err)
return err
}
j.Status = status
// If job is set to done, remove it after 1 minute.
if status == JOB_DONE || status == JOB_CANCELLED {
slog.Debug("updateJobStatus: Job set to done or cancelled. Will be removed after 30m.", "id", id, "status", status)
go func() {
time.Sleep(30 * time.Minute)
slog.Debug("updateJobStatus: Job done. waited 30m.. removing job now.", "id", id)
rmJob(id, userId)
}()
}
return nil
}
// Update a jobs current task.
func updateJobCurrentTask(id string, userId uint, ct string) error {
j, err := getJob(id, userId)
if err != nil {
slog.Error("updateJobCurrentTask: Failed!", "ct", ct, "error", err)
return err
}
j.CurrentTask = ct
return nil
}
// Add an error to a job.
func addJobError(id string, userId uint, e string) error {
j, err := getJob(id, userId)
if err != nil {
slog.Error("updateJobCurrentTask: Failed!", "e", e, "error", err)
return err
}
j.Errors = append(j.Errors, e)
return nil
}
+17 -6
View File
@@ -8,13 +8,24 @@ import (
// Location middleware
func WhereaboutsRequired() gin.HandlerFunc {
// TODO: This should also take into account a user
// preference (of location.. which also needs to be
// added) and server preference (env var). The US is
// good enough for now as a default.
return func(c *gin.Context) {
slog.Debug("WhereaboutsRequired middleware hit", "country", "US")
c.Set("userCountry", "US")
region := c.Query("region")
slog.Debug("WhereaboutsRequired: middleware hit", "region", region)
if region == "" {
// If no region is passed, default to server region.
if Config.DEFAULT_COUNTRY != "" {
slog.Debug("WhereaboutsRequired: Using server default country.", "default_country", Config.DEFAULT_COUNTRY)
c.Set("userCountry", Config.DEFAULT_COUNTRY)
c.Next()
return
}
// If no server region set, default to US.
slog.Debug("WhereaboutsRequired: Using hard coded default (US).")
c.Set("userCountry", "US")
c.Next()
return
}
c.Set("userCountry", region)
c.Next()
}
}
+586
View File
@@ -0,0 +1,586 @@
package main
import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type PlexLoginRequest struct {
AuthToken string `json:"token" binding:"required"`
ClientIdentifier string `json:"clientIdentifier" binding:"required"`
}
type PlexUser struct {
Id uint64 `json:"id" binding:"required"`
Uuid string `json:"uuid" binding:"required"`
Email string `json:"email" binding:"required"`
Username string `json:"username" binding:"required"`
}
type PlexAccountResponse struct {
User PlexUser `json:"user" binding:"required"`
}
type PlexIdentity struct {
MediaContainer struct {
MachineIdentifier string `json:"machineIdentifier"`
} `json:"MediaContainer"`
}
type PlexHostConfigUpdateResponse struct {
PLEX_MACHINE_ID string
}
// Plex get libraries response
// /library/sections
type PlexLibrariesResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Title1 string `json:"title1"`
Directory []struct {
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Composite string `json:"composite"`
Filters bool `json:"filters"`
Refreshing bool `json:"refreshing"`
Thumb string `json:"thumb"`
Key string `json:"key"`
Type string `json:"type"`
Title string `json:"title"`
Agent string `json:"agent"`
Scanner string `json:"scanner"`
Language string `json:"language"`
UUID string `json:"uuid"`
UpdatedAt int `json:"updatedAt"`
CreatedAt int `json:"createdAt"`
ScannedAt int `json:"scannedAt"`
Content bool `json:"content"`
Directory bool `json:"directory"`
ContentChangedAt int64 `json:"contentChangedAt"`
Hidden int `json:"hidden"`
Location []struct {
ID int `json:"id"`
Path string `json:"path"`
} `json:"Location"`
} `json:"Directory"`
} `json:"MediaContainer"`
}
// Plex get all library items response (with includeGuids parameter)
// /library/sections/{sectionId}/all
type PlexLibraryItemsResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Identifier string `json:"identifier"`
LibrarySectionID int `json:"librarySectionID"`
LibrarySectionTitle string `json:"librarySectionTitle"`
LibrarySectionUUID string `json:"librarySectionUUID"`
MediaTagPrefix string `json:"mediaTagPrefix"`
MediaTagVersion int `json:"mediaTagVersion"`
Thumb string `json:"thumb"`
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
Metadata []struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
GUID string `json:"guid"` // Plex guid
Slug string `json:"slug"`
Studio string `json:"studio"`
Type string `json:"type"`
Title string `json:"title"`
ContentRating string `json:"contentRating"`
Summary string `json:"summary"`
Rating float64 `json:"rating"`
UserRating float64 `json:"userRating"`
AudienceRating float64 `json:"audienceRating"`
ViewCount int `json:"viewCount,omitempty"`
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
Year int `json:"year"`
Tagline string `json:"tagline"`
Thumb string `json:"thumb"`
Art string `json:"art"`
Duration int `json:"duration"`
OriginallyAvailableAt string `json:"originallyAvailableAt"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
AudienceRatingImage string `json:"audienceRatingImage"`
HasPremiumPrimaryExtra string `json:"hasPremiumPrimaryExtra"`
RatingImage string `json:"ratingImage"`
LeafCount int `json:"leafCount,omitempty"`
ViewedLeafCount int `json:"viewedLeafCount,omitempty"`
Media []struct {
ID int `json:"id"`
Duration int `json:"duration"`
Bitrate int `json:"bitrate"`
Width int `json:"width"`
Height int `json:"height"`
AspectRatio float64 `json:"aspectRatio"`
AudioChannels int `json:"audioChannels"`
AudioCodec string `json:"audioCodec"`
VideoCodec string `json:"videoCodec"`
VideoResolution string `json:"videoResolution"`
Container string `json:"container"`
VideoFrameRate string `json:"videoFrameRate"`
VideoProfile string `json:"videoProfile"`
Part []struct {
ID int `json:"id"`
Key string `json:"key"`
Duration int `json:"duration"`
File string `json:"file"`
Size int64 `json:"size"`
Container string `json:"container"`
HasThumbnail string `json:"hasThumbnail"`
VideoProfile string `json:"videoProfile"`
} `json:"Part"`
} `json:"Media"`
// External ids
Guid []struct {
ID string `json:"id"`
} `json:"Guid"`
Genre []struct {
Tag string `json:"tag"`
} `json:"Genre"`
Country []struct {
Tag string `json:"tag"`
} `json:"Country"`
Director []struct {
Tag string `json:"tag"`
} `json:"Director"`
Writer []struct {
Tag string `json:"tag"`
} `json:"Writer"`
Role []struct {
Tag string `json:"tag"`
} `json:"Role"`
ChapterSource string `json:"chapterSource,omitempty"`
} `json:"Metadata"`
} `json:"MediaContainer"`
}
// Plex get all children (seasons) for a serie.
// /library/metadata/{ratingKey}/children
type PlexLibraryItemSeasonsResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Identifier string `json:"identifier"`
Key string `json:"key"`
LibrarySectionID int `json:"librarySectionID"`
LibrarySectionTitle string `json:"librarySectionTitle"`
LibrarySectionUUID string `json:"librarySectionUUID"`
MediaTagPrefix string `json:"mediaTagPrefix"`
MediaTagVersion int `json:"mediaTagVersion"`
Nocache bool `json:"nocache"`
ParentIndex int `json:"parentIndex"`
ParentTitle string `json:"parentTitle"`
ParentYear int `json:"parentYear"`
Summary string `json:"summary"`
Theme string `json:"theme"`
Thumb string `json:"thumb"`
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
Directory []struct {
LeafCount int `json:"leafCount"`
Thumb string `json:"thumb"`
ViewedLeafCount int `json:"viewedLeafCount"`
Key string `json:"key"`
Title string `json:"title"`
} `json:"Directory"`
Metadata []struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
ParentRatingKey string `json:"parentRatingKey"`
GUID string `json:"guid"`
ParentGUID string `json:"parentGuid"`
ParentSlug string `json:"parentSlug"`
ParentStudio string `json:"parentStudio"`
Type string `json:"type"`
Title string `json:"title"`
ParentKey string `json:"parentKey"`
ParentTitle string `json:"parentTitle"`
Summary string `json:"summary"`
Index int `json:"index"`
ParentIndex int `json:"parentIndex"`
UserRating float64 `json:"userRating,omitempty"`
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
ParentYear int `json:"parentYear"`
Thumb string `json:"thumb"`
Art string `json:"art"`
ParentThumb string `json:"parentThumb"`
ParentTheme string `json:"parentTheme"`
LeafCount int `json:"leafCount"`
ViewedLeafCount int `json:"viewedLeafCount"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
ViewCount int `json:"viewCount,omitempty"`
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
} `json:"Metadata"`
} `json:"MediaContainer"`
}
// Plex get all children (episodes) for a season.
// /library/metadata/{ratingKey}/allLeaves
type PlexLibraryItemEpisodesResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Identifier string `json:"identifier"`
Key string `json:"key"`
LibrarySectionID int `json:"librarySectionID"`
LibrarySectionTitle string `json:"librarySectionTitle"`
LibrarySectionUUID string `json:"librarySectionUUID"`
MediaTagPrefix string `json:"mediaTagPrefix"`
MediaTagVersion int `json:"mediaTagVersion"`
MixedParents bool `json:"mixedParents"`
Nocache bool `json:"nocache"`
ParentIndex int `json:"parentIndex"`
ParentTitle string `json:"parentTitle"`
ParentYear int `json:"parentYear"`
Theme string `json:"theme"`
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
Metadata []struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
ParentRatingKey string `json:"parentRatingKey"`
GrandparentRatingKey string `json:"grandparentRatingKey"`
GUID string `json:"guid"`
GrandparentSlug string `json:"grandparentSlug"`
Studio string `json:"studio"`
Type string `json:"type"`
Title string `json:"title"`
GrandparentKey string `json:"grandparentKey"`
ParentKey string `json:"parentKey"`
GrandparentTitle string `json:"grandparentTitle"`
ParentTitle string `json:"parentTitle"`
ContentRating string `json:"contentRating"`
Summary string `json:"summary"`
Index int `json:"index"`
ParentIndex int `json:"parentIndex"`
AudienceRating float64 `json:"audienceRating"`
Year int `json:"year"`
Thumb string `json:"thumb"`
Art string `json:"art"`
ParentThumb string `json:"parentThumb"`
GrandparentThumb string `json:"grandparentThumb"`
GrandparentArt string `json:"grandparentArt"`
GrandparentTheme string `json:"grandparentTheme"`
Duration int `json:"duration"`
OriginallyAvailableAt string `json:"originallyAvailableAt"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
AudienceRatingImage string `json:"audienceRatingImage"`
ChapterSource string `json:"chapterSource"`
TitleSort string `json:"titleSort,omitempty"`
UserRating float64 `json:"userRating,omitempty"`
ViewCount int `json:"viewCount,omitempty"`
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
} `json:"Metadata"`
} `json:"MediaContainer"`
}
// Response from clients.plex.tv/api/v2/resources
// Used to get users auth token for home plex server.
type PlexClientResources []struct {
Name string `json:"name"`
Product string `json:"product"`
ProductVersion string `json:"productVersion"`
Platform string `json:"platform"`
PlatformVersion string `json:"platformVersion"`
Device string `json:"device"`
ClientIdentifier string `json:"clientIdentifier"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
Provides string `json:"provides"`
OwnerID interface{} `json:"ownerId"`
SourceTitle interface{} `json:"sourceTitle"`
PublicAddress string `json:"publicAddress"`
AccessToken string `json:"accessToken"`
Owned bool `json:"owned"`
Home bool `json:"home"`
Synced bool `json:"synced"`
Relay bool `json:"relay"`
Presence bool `json:"presence"`
HTTPSRequired bool `json:"httpsRequired"`
PublicAddressMatches bool `json:"publicAddressMatches"`
DNSRebindingProtection bool `json:"dnsRebindingProtection"`
NatLoopbackSupported bool `json:"natLoopbackSupported"`
Connections []struct {
Protocol string `json:"protocol"`
Address string `json:"address"`
Port int `json:"port"`
URI string `json:"uri"`
Local bool `json:"local"`
Relay bool `json:"relay"`
IPv6 bool `json:"IPv6"`
} `json:"connections"`
}
// Plex access middleware, ensures user is a Plex user.
// To be ran after AuthRequired middleware with extra data.
func PlexAccessRequired(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
userType := c.MustGet("userType").(UserType)
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
c.AbortWithStatus(401)
return
}
if userType != PLEX_USER {
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
c.AbortWithStatus(401)
return
}
userPlexService := new(UserServices)
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
c.AbortWithStatus(401)
return
}
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
c.AbortWithStatus(401)
return
}
c.Set("plexAuthToken", userPlexService.AuthToken)
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
}
}
func getPlexIdentity(host string) (PlexIdentity, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", host+"/identity", nil)
if err != nil {
return PlexIdentity{}, err
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return PlexIdentity{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexIdentity{}, err
}
defer resp.Body.Close()
var pi PlexIdentity
err = json.Unmarshal(body, &pi)
if err != nil {
return PlexIdentity{}, err
}
return pi, nil
}
func fetchPlexAccountFromToken(token string) (PlexUser, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
if err != nil {
return PlexUser{}, err
}
req.Header.Set("X-Plex-Token", token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return PlexUser{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexUser{}, err
}
defer resp.Body.Close()
var pa PlexAccountResponse
err = json.Unmarshal(body, &pa)
if err != nil {
return PlexUser{}, err
}
return pa.User, nil
}
// Update plex host setting
func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
Config.PLEX_HOST = v
if Config.PLEX_HOST != "" {
pi, err := getPlexIdentity(Config.PLEX_HOST)
if err != nil {
slog.Error("updateConfigPlexHost: Failed to get plex server identity!", "error", err)
return PlexHostConfigUpdateResponse{}, errors.New("failed to get Plex server identity. Please try setting the Plex Host again or setting PLEX_MACHINE_ID manually in your config file")
}
if pi.MediaContainer.MachineIdentifier == "" {
slog.Error("updateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
return PlexHostConfigUpdateResponse{}, errors.New("got Plex server identity, but no machine id was found")
}
Config.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
} else {
Config.PLEX_MACHINE_ID = ""
}
if err := writeConfig(); err != nil {
slog.Error("updateConfigPlexHost: Failed to write updated config to file!", "err", err)
return PlexHostConfigUpdateResponse{}, errors.New("failed to write config")
}
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
}
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
if err != nil {
return PlexLibrariesResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibrariesResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibrariesResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibrariesResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibrariesResponse{}, err
}
return pl, nil
}
func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibraryItemsResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
return pl, nil
}
func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibraryItemSeasonsResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
return pl, nil
}
func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibraryItemEpisodesResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
return pl, nil
}
// Gets users auth token for local plex server,
// so they can authenticate against it for api requests.
// If no auth token is returned or errored, assume user doesn't have access to home plex server library.
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
if err != nil {
return "", err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
req.Header.Set("X-Plex-Client-Identifier", userClientId)
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
defer resp.Body.Close()
var pl PlexClientResources
err = json.Unmarshal(body, &pl)
if err != nil {
return "", err
}
authToken := ""
for _, v := range pl {
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
if v.AccessToken == "" {
slog.Error("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
continue
}
authToken = v.AccessToken
}
}
if authToken == "" {
slog.Error("getPlexHomeServerAuthToken: No authToken retrieved!")
}
return authToken, nil
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"errors"
"log/slog"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type PlexSyncResponse struct {
JobId string `json:"jobId"`
}
// Perform a Plex sync.
// Errors are added silently to the job.
func startPlexSync(
db *gorm.DB,
jobId string,
userId uint,
userPlexLocalAuth string,
) {
updateJobCurrentTask(jobId, userId, "fetching libraries")
libraries, err := getPlexLibraries(userPlexLocalAuth)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
addJobError(jobId, userId, "failed to get plex libraries")
updateJobStatus(jobId, userId, JOB_DONE)
return
}
for _, library := range libraries.MediaContainer.Directory {
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
if library.Type == "movie" {
updateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
movies, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
addJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
continue
}
for _, movie := range movies.MediaContainer.Metadata {
if movie.ViewCount == 0 {
// Not viewed and not rated, skip importing
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
// Find tmdb id
if len(movie.Guid) <= 0 {
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
continue
}
tmdbIdStr := ""
for _, v := range movie.Guid {
if strings.HasPrefix(v.ID, "tmdb://") {
tmdbIdStr = v.ID[7:]
break
}
}
if tmdbIdStr == "" {
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
continue
}
tmdbId, err := strconv.Atoi(tmdbIdStr)
if err != nil {
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
continue
}
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: MOVIE,
Rating: float64(movie.UserRating),
WatchedDate: lastViewedAt,
}, IMPORTED_WATCHED_PLEX)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("plexSyncWatched: unique constraint hit. movie must already be on watch list", "error", err)
continue
}
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
addJobError(jobId, userId, "failed to add movie "+movie.Title)
} else {
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
if !lastViewedAt.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{
WatchedID: w.ID,
Type: IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
}
}
}
}
} else if library.Type == "show" {
updateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
shows, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
addJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
continue
}
for _, show := range shows.MediaContainer.Metadata {
if show.ViewedLeafCount != show.LeafCount {
// Not viewed, skip importing
// (could be improved to set status as watching when viewedLeafCount is higher than 0)
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "importing show "+show.Title)
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
tmdbIdStr := ""
for _, v := range show.Guid {
if strings.HasPrefix(v.ID, "tmdb://") {
tmdbIdStr = v.ID[7:]
break
}
}
if tmdbIdStr == "" {
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
continue
}
tmdbId, err := strconv.Atoi(tmdbIdStr)
if err != nil {
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
addJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
continue
}
lastViewedAt := time.Unix(show.LastViewedAt, 0)
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: SHOW,
Rating: float64(show.UserRating),
WatchedDate: lastViewedAt,
}, IMPORTED_WATCHED_PLEX)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Info("plexSyncWatched: unique constraint hit. show must already be on watch list", "error", err)
} else {
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
addJobError(jobId, userId, "failed to add show "+show.Title)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
if !lastViewedAt.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{
WatchedID: w.ID,
Type: IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
}
}
}
// Import watched seasons for this serie
seriesSeasons, err := getPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
} else {
for _, vs := range seriesSeasons.MediaContainer.Metadata {
slog.Debug("plexSyncWatched: Processing a season.", "full_item", vs, "user_id", userId)
if vs.ViewedLeafCount != vs.LeafCount {
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
var seasonLastViewedAt time.Time
if vs.LastViewedAt != 0 {
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
}
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.Index,
Status: FINISHED,
addActivity: SEASON_ADDED_PLEX,
addActivityDate: seasonLastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
}
}
}
// Import watched episodes for this serie
seriesEpisodes, err := getPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
} else {
for _, vs := range seriesEpisodes.MediaContainer.Metadata {
slog.Debug("plexSyncWatched: Processing an episode.", "full_item", vs, "user_id", userId)
if vs.ViewCount <= 0 {
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
var episodeLastViewedAt time.Time
if vs.LastViewedAt != 0 {
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
}
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.ParentIndex,
EpisodeNumber: vs.Index,
Status: FINISHED,
addActivity: EPISODE_ADDED_PLEX,
addActivityDate: episodeLastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
}
}
}
}
}
}
updateJobStatus(jobId, userId, JOB_DONE)
}
func plexSyncWatched(
db *gorm.DB,
userId uint,
userPlexLocalAuth string,
) (PlexSyncResponse, error) {
jobId, err := addJob("plex_sync", userId)
if err != nil {
slog.Error("startPlexSync: Failed to create a job", "error", err)
return PlexSyncResponse{}, errors.New("failed to create job")
}
updateJobStatus(jobId, userId, JOB_RUNNING)
go startPlexSync(
db,
jobId,
userId,
userPlexLocalAuth,
)
return PlexSyncResponse{JobId: jobId}, nil
}
+87 -9
View File
@@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"time"
@@ -9,9 +10,56 @@ import (
)
type Profile struct {
Joined time.Time `json:"joined"`
ShowsWatched int32 `json:"showsWatched"`
MoviesWatched int32 `json:"moviesWatched"`
Joined time.Time `json:"joined"`
ShowsWatched int32 `json:"showsWatched"`
MoviesWatched int32 `json:"moviesWatched"`
MoviesWatchedRuntime uint32 `json:"moviesWatchedRuntime"`
ShowsWatchedRuntime uint32 `json:"showsWatchedRuntime"`
}
// Check if content has been previsouly watched by looking for related activity.
func hasBeenPreviouslyWatched(a *[]Activity) bool {
wp := false
var relatedActivity []Activity
for _, v := range *a {
if v.Type == ADDED_WATCHED ||
v.Type == IMPORTED_ADDED_WATCHED ||
v.Type == IMPORTED_WATCHED ||
v.Type == STATUS_CHANGED {
relatedActivity = append(relatedActivity, v)
}
}
if len(relatedActivity) <= 0 {
return false
}
for _, ra := range relatedActivity {
if ra.Type == IMPORTED_ADDED_WATCHED {
wp = true
break
} else if ra.Type == ADDED_WATCHED || ra.Type == IMPORTED_WATCHED {
if ra.Data == "" {
continue
}
var v map[string]any
err := json.Unmarshal([]byte(ra.Data), &v)
if err != nil {
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
continue
}
if status, ok := v["status"]; ok {
if status == "FINISHED" {
wp = true
break
}
}
} else if ra.Type == STATUS_CHANGED {
if ra.Data == "FINISHED" {
wp = true
break
}
}
}
return wp
}
// Gets any data required for profile page
@@ -23,24 +71,54 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
return Profile{}, errors.New("failed to get profile")
}
watched := new([]Watched)
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
res = db.Model(&Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
if res.Error != nil {
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
return Profile{}, errors.New("failed to get watched for processing")
}
var (
showsWatched int32
moviesWatched int32
showsWatched int32
moviesWatched int32
moviesWatchedRuntime uint32
showsWatchedRuntime uint32
)
for _, w := range *watched {
isFinished := false
if w.Status == FINISHED {
if w.Content.Type == SHOW {
isFinished = true
} else if *user.IncludePreviouslyWatched && hasBeenPreviouslyWatched(&w.Activity) {
// If status is not finished and user has IncludePreviouslyWatched enabled,
// then we can also check if content hasBeenPreviouslyWatched.
isFinished = true
}
if isFinished {
if w.Content == nil {
continue
}
c := *w.Content
if c.Type == SHOW {
showsWatched++
} else if w.Content.Type == MOVIE {
// This aint a science, just a very inaccurate guesstimate.
if c.NumberOfEpisodes != 0 {
var showRuntime uint32 = 30
if c.Runtime != 0 {
showRuntime = c.Runtime
}
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
}
} else if c.Type == MOVIE {
moviesWatched++
moviesWatchedRuntime += c.Runtime
}
}
}
profile := Profile{Joined: user.CreatedAt, ShowsWatched: showsWatched, MoviesWatched: moviesWatched}
profile := Profile{
Joined: user.CreatedAt,
ShowsWatched: showsWatched,
MoviesWatched: moviesWatched,
MoviesWatchedRuntime: moviesWatchedRuntime,
ShowsWatchedRuntime: showsWatchedRuntime,
}
return profile, nil
}
+915 -68
View File
File diff suppressed because it is too large Load Diff
+194
View File
@@ -0,0 +1,194 @@
package main
import (
"errors"
"log/slog"
"gorm.io/gorm"
)
// I think tags will be private for the user.
// If the user wants to make a public list, they should make a custom view.
type Tag struct {
GormModel
// ID of user that own this tag.
UserID uint `json:"-" gorm:"not null"`
// Name of the tag.
Name string `json:"name" gorm:"not null"`
// Hex of text color.
Color string `json:"color"`
// Hex of background color.
BgColor string `json:"bgColor"`
// All watched items.
Watched []Watched `json:"watched,omitempty" gorm:"many2many:watched_tags;"`
}
type TagAddRequest struct {
Name string `json:"name" binding:"required"`
Color string `json:"color"`
BgColor string `json:"bgColor"`
}
func getTags(db *gorm.DB, userId uint) ([]Tag, error) {
tags := new([]Tag)
res := db.Model(&Tag{}).Where("user_id = ?", userId).Find(&tags)
if res.Error != nil {
slog.Error("getTags: Failed getting tags from database", "error", res.Error.Error())
return []Tag{}, errors.New("failed getting tags")
}
return *tags, nil
}
// func getTag(db *gorm.DB, userId uint, tagId uint) (Tag, error) {
// tag := new(Tag)
// res := db.Model(&Tag{}).Where("id = ? AND user_id = ?", tagId, userId).Preload("Watched").Find(&tag)
// if res.Error != nil {
// slog.Error("getTag: Failed getting tag from database", "error", res.Error.Error())
// return Tag{}, errors.New("failed getting tag")
// }
// if tag.ID == 0 {
// slog.Error("getTag: Tag does not exist for this user.", "user_id", userId)
// return Tag{}, errors.New("tag does not exist")
// }
// return *tag, nil
// }
// This method should only be used when we don't have the tagId
// (eg: when we are importing data) because this is not technically
// reliable, since users can have multiple tags with the same name/colors
// (realistically they probably won't, but...).
func getTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (Tag, error) {
tag := new(Tag)
res := db.Model(&Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
if res.Error != nil {
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
return Tag{}, errors.New("failed getting tag")
}
if tag.ID == 0 {
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
return Tag{}, errors.New("tag does not exist")
}
return *tag, nil
}
// Let user create a tag.
func addTag(db *gorm.DB, userId uint, tr TagAddRequest) (Tag, error) {
if tr.Name == "" {
return Tag{}, errors.New("tag must have a name")
}
tag := Tag{UserID: userId, Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
res := db.Create(&tag)
if res.Error != nil {
slog.Error("Error adding tag to database", "error", res.Error.Error())
return Tag{}, errors.New("failed adding new tag to database")
}
slog.Debug("Adding tag", "added_tag", tag)
return tag, nil
}
// Let user update one of their tags (replaces).
func updateTag(db *gorm.DB, userId uint, tagId uint, tr TagAddRequest) error {
if tr.Name == "" {
return errors.New("tag must have a name")
}
tag := Tag{Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
res := db.Where("id = ? AND user_id = ?", tagId, userId).Updates(&tag)
if res.Error != nil {
slog.Error("Error updating tag in database", "error", res.Error.Error())
return errors.New("failed updating tag in database")
}
if res.RowsAffected == 0 {
slog.Error("updateTag: Zero rows affected.. tag likely does not exist", "tag_id", tagId, "user_id", userId)
return errors.New("tag does not exist")
}
slog.Debug("updateTag:", "updated_tag", tag)
return nil
}
// Let user delete their own tag.
func deleteTag(db *gorm.DB, userId uint, tagId uint) error {
if tagId == 0 {
return errors.New("no tag id provided")
}
slog.Debug("deleteTag:", "tag_id", tagId, "user_id", userId)
// Select("Watched") so relations in watched_tags table are removed too.
// ID is passed in the .Delete param so the .Select call can do it's job (relies on the primary key).
res := db.Unscoped().Where("id = ? AND user_id = ?", tagId, userId).Select("Watched").Delete(&Tag{GormModel: GormModel{ID: tagId}})
if res.Error != nil {
slog.Error("deleteTag: Error deleting tag from database", "error", res.Error.Error(), "tag_id", tagId, "user_id", userId)
return errors.New("failed deleting tag from database")
}
if res.RowsAffected == 0 {
slog.Error("deleteTag: Zero rows affected.. tag must not exist for user", "tag_id", tagId, "user_id", userId)
return errors.New("tag does not exist")
}
return nil
}
// Add watched content to a tag (user must own the tag and watched entry).
func addWatchedToTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
slog.Debug("addWatchedToTag: Adding", "userId", userId, "watchedID", watchedId, "tagId", tagId)
// 1. Make sure watched item exists and is owned by this user
var w Watched
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
slog.Error("addWatchedToTag: failed to get watched item from db", "error", resp.Error)
return errors.New("failed when retrieving watched item")
}
if w.ID == 0 {
slog.Error("addWatchedToTag", "error", "watched item does not exist in db", "watchedID", watchedId)
return errors.New("watched entry does not exist")
}
// 2. Make sure tag exists
var t Tag
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
slog.Error("addWatchedToTag: Failed to get tag from db", "error", resp.Error)
return errors.New("failed when retrieving tag")
}
if t.ID == 0 {
slog.Error("addWatchedToTag", "error", "tag does not exist in db", "tagId", tagId)
return errors.New("tag does not exist")
}
// 3. Save relation (unique restraint will fail if it already exists)
w.Tags = append(w.Tags, t)
resp := db.Save(&w)
if resp.Error != nil {
slog.Error("addWatchedToTag: Failed to tag watched item", "error", resp.Error)
return errors.New("failed to tag watched item")
}
slog.Debug("addWatchedToTag: watched content successfully linked to tag", "watchedID", watchedId, "tagId", tagId)
return nil
}
// Remove watched content from a tag (user must own the tag and watched entry).
func rmWatchedFromTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
slog.Debug("rmWatchedFromTag: Removing", "userId", userId, "watchedID", watchedId, "tagId", tagId)
// 1. Make sure watched item exists and is owned by this user
var w Watched
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
slog.Error("rmWatchedFromTag: failed to get watched item from db", "error", resp.Error)
return errors.New("failed when retrieving watched item")
}
if w.ID == 0 {
slog.Error("rmWatchedFromTag", "error", "watched item does not exist in db", "watchedID", watchedId)
return errors.New("watched entry does not exist")
}
// 2. Make sure tag exists
var t Tag
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
slog.Error("rmWatchedFromTag: Failed to get tag from db", "error", resp.Error)
return errors.New("failed when retrieving tag")
}
if t.ID == 0 {
slog.Error("rmWatchedFromTag", "error", "tag does not exist in db", "tagId", tagId)
return errors.New("tag does not exist")
}
// 3. Remove relation
err := db.Model(&w).Association("Tags").Delete(&t)
if err != nil {
slog.Error("rmWatchedFromTag: Failed to untag watched item", "error", err)
return errors.New("failed to untag watched item")
}
slog.Debug("rmWatchedFromTag: watched content successfully removed from tag", "watchedID", watchedId, "tagId", tagId)
return nil
}
+161 -8
View File
@@ -1,20 +1,173 @@
package main
import (
"errors"
"log/slog"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
)
type TaskRescheduleRequest struct {
// Number of seconds inbetween each run of this task.
Seconds int `json:"seconds" binding:"required"`
}
type AllTasksResponse struct {
// The tasks name.
Name string `json:"name"`
// When this task will next run.
NextRun time.Time `json:"nextRun"`
// Current schedule for this task (seconds).
Seconds int `json:"seconds"`
}
type TaskFunc struct {
// Task function.
f func()
// Default duration (schedule) for task.
dd time.Duration
}
var taskScheduler gocron.Scheduler
// All task functions are stored here so when updating (rescheduling)
// a job, we can give it this function again.
// Doesn't seem to be a way to only update the schedule of a job,
// the .Update func wants the whole definition again.
//
// All funcs simply call a cleaning/routine method where the rest of the
// related code lives so it's kept tidy.
var taskFuncs map[string]TaskFunc
// Setup recurring tasks (eg cleanup every x mins)
func setupTasks(db *gorm.DB) {
taskRunInterval := 1 * time.Minute
ticker := time.NewTicker(taskRunInterval)
defer ticker.Stop()
for range ticker.C {
// Runs funcs that are in the place where we are cleaning.
// Bit cleaner and we can keep the related code close to its home.
cleanupTokens(db)
ts, err := gocron.NewScheduler()
if err != nil {
slog.Error("SetupTasks: Failed to create new scheduler!", "error", err)
return
}
taskScheduler = ts
// Define all task funcs.
taskFuncs = map[string]TaskFunc{
"Cleanup Tokens": {
f: func() {
cleanupTokens(db)
},
dd: 60 * time.Second,
},
"Refresh Arr Queues": {
f: func() {
refreshArrQueues()
},
dd: 60 * time.Second,
},
"Cleanup Images": {
f: func() {
cleanupImages(db)
},
dd: 24 * time.Hour,
},
}
// Add all jobs to scheduler.
for k, v := range taskFuncs {
err = addTaskToScheduler(k, v.dd)
if err != nil {
slog.Error("SetupTasks: Failed to add new job", "job", k, "err", err)
}
}
taskScheduler.Start()
slog.Info("SetupTasks: Jobs created and scheduler started.")
}
// Gets schedule from config, or `defaultDur` if not manually configured.
func getTaskSeconds(name string, defaultDur time.Duration) time.Duration {
s := defaultDur
if Config.TASK_SCHEDULE[name] != 0 {
s = time.Duration(Config.TASK_SCHEDULE[name]) * time.Second
}
return s
}
// Add new job to scheduler.
func addTaskToScheduler(name string, defaultDur time.Duration) error {
s := getTaskSeconds(name, defaultDur)
_, err := taskScheduler.NewJob(
gocron.DurationJob(s),
gocron.NewTask(taskFuncs[name].f),
gocron.WithName(name),
)
slog.Debug("addTaskToScheduler: Job added.", "job_name", name, "duration_used", s, "duration_default", defaultDur)
return err
}
// Get all tasks in a consumable format.
func getAllTasks() []AllTasksResponse {
jobs := []AllTasksResponse{}
for _, j := range taskScheduler.Jobs() {
j2a := AllTasksResponse{
Name: j.Name(),
}
nextRun, err := j.NextRun()
if err != nil {
slog.Error("getAllTasks: Failed to get next run time for a job.", "job_name", j2a.Name)
} else {
j2a.NextRun = nextRun
}
j2a.Seconds = int(getTaskSeconds(j2a.Name, taskFuncs[j2a.Name].dd).Seconds())
jobs = append(jobs, j2a)
}
return jobs
}
// Get task (job) from scheduler by name.
func getTask(name string) *gocron.Job {
var job *gocron.Job
for _, j := range taskScheduler.Jobs() {
if j.Name() == name {
job = &j
break
}
}
return job
}
// Reschedule a task by name.
func rescheduleTask(name string, req TaskRescheduleRequest) error {
if req.Seconds == 0 {
return errors.New("request has no seconds")
}
j := getTask(name)
if j == nil {
return errors.New("no task found")
}
// Update config
if Config.TASK_SCHEDULE == nil {
Config.TASK_SCHEDULE = map[string]int{}
}
Config.TASK_SCHEDULE[name] = req.Seconds
if err := writeConfig(); err != nil {
slog.Error("rescheduleTask: Failed to write updated config to file!", "error", err)
return errors.New("failed to write config")
}
// Update job in scheduler
_, err := taskScheduler.Update(
(*j).ID(),
gocron.DurationJob(
time.Duration(req.Seconds)*time.Second,
),
gocron.NewTask(
taskFuncs[name].f,
),
gocron.WithName(name),
)
if err != nil {
slog.Error("rescheduleTask: Failed to update job!", "error", err)
return errors.New("failed to update job")
}
return nil
}
+88
View File
@@ -40,6 +40,86 @@ type TMDBSearchMultiResults struct {
OriginCountry []string `json:"origin_country,omitempty"`
}
type TMDBSearchMoviesResponse struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
ReleaseDate string `json:"release_date"`
Title string `json:"title"`
Video bool `json:"video"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBSearchShowsResponse struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
OriginCountry []string `json:"origin_country"`
OriginalLanguage string `json:"original_language"`
OriginalName string `json:"original_name"`
Overview string `json:"overview"`
Popularity float64 `json:"popularity"`
PosterPath string `json:"poster_path"`
FirstAirDate string `json:"first_air_date"`
Name string `json:"name"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBSearchPeopleResponse struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
Gender int `json:"gender"`
ID int `json:"id"`
KnownForDepartment string `json:"known_for_department"`
Name string `json:"name"`
OriginalName string `json:"original_name"`
Popularity float64 `json:"popularity"`
ProfilePath string `json:"profile_path"`
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
KnownFor []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title"`
Overview string `json:"overview"`
PosterPath string `json:"poster_path"`
MediaType string `json:"media_type"`
GenreIds []int `json:"genre_ids"`
Popularity float64 `json:"popularity"`
ReleaseDate string `json:"release_date"`
Video bool `json:"video"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"known_for"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBContentDetails struct {
ID int `json:"id"`
BackdropPath string `json:"backdrop_path"`
@@ -491,6 +571,14 @@ type TMDBKeywords struct {
} `json:"results"`
}
type TMDBRegions struct {
Results []struct {
ISO3166_1 string `json:"iso_3166_1"`
English_Name string `json:"english_name"`
Native_Name string `json:"native_name"`
} `json:"results"`
}
func getTMDBKey() string {
if Config.TMDB_KEY != "" {
return Config.TMDB_KEY
+133 -6
View File
@@ -1,9 +1,16 @@
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"log"
"log/slog"
"path"
"path/filepath"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -11,6 +18,9 @@ import (
type PublicUser struct {
ID uint `json:"id"`
Username string `json:"username"`
AvatarID uint `json:"-"`
Avatar Image `json:"avatar"`
Bio string `json:"bio,omitempty"`
}
// Private user details, for returning users details to themselves
@@ -18,6 +28,13 @@ type PrivateUser struct {
Username string `json:"username"`
Type UserType `json:"type"`
Permissions int `json:"permissions"`
AvatarID uint `json:"-"`
Avatar Image `json:"avatar"`
Bio string `json:"bio"`
}
type UserBioUpdateRequest struct {
NewBio string `json:"newBio" binding:"max=128"`
}
// Update user settings
@@ -26,7 +43,7 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
user := new(User)
res := db.Where("id = ?", userId).Take(&user)
if res.Error != nil {
slog.Error("user update failed", "user_id", userId, "error", "failed to retrieve user from database")
slog.Error("user update failed", "user_id", userId, "error", res.Error)
return UserSettings{}, errors.New("failed to retrieve user")
}
if ur.HideSpoilers != nil {
@@ -35,8 +52,33 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
if ur.Private != nil {
user.Private = ur.Private
}
if ur.PrivateThoughts != nil {
user.PrivateThoughts = ur.PrivateThoughts
}
if ur.IncludePreviouslyWatched != nil {
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
}
if ur.AutomateShowStatuses != nil {
user.AutomateShowStatuses = ur.AutomateShowStatuses
}
if ur.Country != nil {
user.Country = ur.Country
}
if ur.RatingSystem != nil {
user.RatingSystem = ur.RatingSystem
}
if ur.RatingStep != nil {
user.RatingStep = ur.RatingStep
}
db.Save(&user)
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
return UserSettings{
Private: user.Private,
PrivateThoughts: user.PrivateThoughts,
HideSpoilers: user.HideSpoilers,
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
AutomateShowStatuses: user.AutomateShowStatuses,
Country: user.Country,
}, nil
}
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
@@ -44,10 +86,19 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
user := new(User)
res := db.Where("id = ?", userId).Take(&user)
if res.Error != nil {
slog.Error("user get failed", "user_id", userId, "error", "failed to retrieve user from database")
slog.Error("user get failed", "user_id", userId, "error", res.Error)
return UserSettings{}, errors.New("failed to retrieve user")
}
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
return UserSettings{
Private: user.Private,
PrivateThoughts: user.PrivateThoughts,
HideSpoilers: user.HideSpoilers,
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
AutomateShowStatuses: user.AutomateShowStatuses,
Country: user.Country,
RatingSystem: user.RatingSystem,
RatingStep: user.RatingStep,
}, nil
}
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
@@ -55,7 +106,7 @@ func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error
users := new([]PublicUser)
res := db.Where("private = 0 AND username LIKE ? AND id != ?", "%"+q+"%", currentUsersId).Table("users").Find(&users)
if res.Error != nil {
slog.Error("user search failed", "error", "failed to query database")
slog.Error("user search failed", "error", res.Error)
return []PublicUser{}, errors.New("failed to find users")
}
return *users, nil
@@ -64,10 +115,86 @@ func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error
func getUserInfo(db *gorm.DB, currentUsersId uint) (PrivateUser, error) {
slog.Debug("user get info request running")
user := new(PrivateUser)
res := db.Where("id = ?", currentUsersId).Table("users").Take(&user)
res := db.Where("id = ?", currentUsersId).Table("users").Preload("Avatar").Take(&user)
if res.Error != nil {
slog.Error("user get info failed", "error", res.Error)
return PrivateUser{}, errors.New("failed to find current user")
}
return *user, nil
}
// For getting a public user's info, when viewing their list for example
func getUserPublicInfo(db *gorm.DB, userId uint, username string) (PublicUser, error) {
slog.Debug("user get info request running")
user := new(PublicUser)
res := db.Where("private = 0 AND id = ? AND username = ?", userId, username).Table("users").Preload("Avatar").Take(&user)
if res.Error != nil {
slog.Error("public user get info failed", "error", res.Error)
return PublicUser{}, errors.New("failed to find user")
}
return *user, nil
}
func userUpdateBio(db *gorm.DB, userId uint, newBio string) error {
slog.Debug("userUpdateBio request running", "user_id", userId, "newBio", newBio)
if res := db.Model(&User{}).Where("id = ?", userId).Update("bio", newBio); res.Error != nil {
slog.Error("userUpdateBio failed", "user_id", userId, "error", res.Error)
return errors.New("failed to update bio")
}
return nil
}
func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
file, err := c.FormFile("avatar")
if err != nil {
slog.Error("failed to get file", "error", err)
return Image{}, errors.New("no file found")
}
slog.Debug("an avatar is being uploaded", "name", file.Filename)
f, _ := file.Open()
if err := isValidImageType(f); err != nil {
return Image{}, errors.New("invalid image type")
}
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
log.Fatal(err) // TODO nu le fatal
}
hs := hex.EncodeToString(h.Sum(nil))
slog.Debug("image hash calculated", "hash", hs, "first_letter", hs[0:1])
// Upload the file to specific dst.
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
c.SaveUploadedFile(file, path.Join(DataPath, outp))
_, err = f.Seek(0, 0)
if err != nil {
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
}
// No need to remove old image, the daily cleanup task will handle removing unused ones.
var img Image
err = db.Transaction(func(tx *gorm.DB) error {
// Insert avatar into db
img, err = insertImage(db, hs, outp, f)
if err != nil {
return err
}
if img.ID == 0 {
return errors.New("image has no id")
}
// Update users avatar to newly inserted
if err := tx.Where("id = ?", userId).Updates(&User{AvatarID: img.ID}).Error; err != nil {
return err
}
// commit transaction if no errors
return nil
})
if err != nil {
slog.Error("uploadUserAvatar failed!", "error", err)
return Image{}, errors.New("uploadUserAvatar transaction failed")
}
return img, nil
}
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"errors"
"log/slog"
"time"
"gorm.io/gorm"
)
// User details wanted for management views.
type ManagedUser struct {
ID uint `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Username string `json:"username"`
Type UserType `json:"type"`
Permissions int `json:"permissions"`
Private bool `json:"private"`
}
type UpdateUserRequest struct {
Permissions *int `json:"permissions"`
}
func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
users := []ManagedUser{}
if res := db.Model(&User{}).Find(&users); res.Error != nil {
slog.Error("getAllUsers: Failed to fetch users from database", "error", res.Error)
return []ManagedUser{}, errors.New("failed to fetch users from database")
}
return users, nil
}
// Update a user. For management views, for admin to update another user.
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
// Error now if no userId or any UpdateUserRequest property was provided.
if userId == 0 || (ur.Permissions == nil) {
slog.Error("manageUser: invalid arguments", "user_id", userId)
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
}
toUpdate := map[string]interface{}{}
if ur.Permissions != nil {
if *ur.Permissions == 0 {
// If removing all perms, set to default of 1 (PERM_NONE).
// Will avoid confusion and possibly bugs later on, though I doubt
// we'd ever be (directly) checking a user to ensure they have no perms.
toUpdate["permissions"] = PERM_NONE
} else {
toUpdate["permissions"] = *ur.Permissions
}
}
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
return errors.New("failed to update user in database")
}
slog.Debug("manageUser: A user has been updated", "user_id", userId)
return nil
}
+44 -9
View File
@@ -10,6 +10,7 @@ import (
"net/http/httputil"
"os"
"os/exec"
"path"
"time"
"github.com/gin-contrib/cors"
@@ -51,7 +52,7 @@ func main() {
setLoggingLevel()
// Ensure data dir exists
err = ensureDirExists("./data")
err = ensureDirExists(DataPath)
if err != nil {
log.Fatal("Failed to create data dir:", err)
}
@@ -63,12 +64,26 @@ func main() {
isProd = false
}
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(path.Join(DataPath, "watcharr.db")), &gorm.Config{TranslateError: true})
if err != nil {
log.Fatal("Failed to connect to database:", err)
}
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &WatchedSeason{}, &Activity{}, &Token{})
err = db.AutoMigrate(
&User{},
&UserServices{},
&Content{},
&Watched{},
&WatchedSeason{},
&WatchedEpisode{},
&Activity{},
&Token{},
&Follow{},
&Image{},
&Game{},
&ArrRequest{},
&Tag{},
)
if err != nil {
log.Fatal("Failed to auto migrate database:", err)
}
@@ -80,10 +95,23 @@ func main() {
gin.DefaultWriter = multiw
gine := gin.Default()
gine.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With"},
ExposeHeaders: []string{"Content-Length"},
AllowOrigins: []string{"*"},
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowHeaders: []string{
"Content-Type",
"Content-Length",
"Accept-Encoding",
"X-CSRF-Token",
"Authorization",
"accept",
"origin",
"Cache-Control",
"X-Requested-With",
},
ExposeHeaders: []string{
"Content-Length",
"watcharr-lastviewedseason-saved",
},
AllowCredentials: true,
MaxAge: 12 * time.Hour,
}))
@@ -114,17 +142,24 @@ func main() {
}
br.addAuthRoutes()
br.addContentRoutes()
br.addGameRoutes()
br.addWatchedRoutes()
br.addActivityRoutes()
br.addProfileRoutes()
br.addJellyfinRoutes()
br.addPlexRoutes()
br.addUserRoutes()
br.addFollowRoutes()
br.addImportRoutes()
br.addServerRoutes()
br.addFeatureRoutes()
br.addSonarrRoutes()
br.addRadarrRoutes()
br.rg.Static("/img", "./data/img")
br.addArrRequestRoutes()
br.addJobRoutes()
br.addTaskRoutes()
br.addTagRoutes()
br.rg.Static("/img", path.Join(DataPath, "img"))
go setupTasks(db)
@@ -135,7 +170,7 @@ func main() {
func setupLogging() io.Writer {
// logLevel = new(slog.LevelVar)
multiw := io.MultiWriter(&lumberjack.Logger{
Filename: "./data/watcharr.log",
Filename: path.Join(DataPath, "watcharr.log"),
MaxSize: 1, // megabytes
MaxBackups: 3,
MaxAge: 28, // days
+99 -143
View File
@@ -10,7 +10,6 @@ import (
"os"
"path"
"strconv"
"strings"
"time"
"gorm.io/gorm"
@@ -22,34 +21,50 @@ const (
FINISHED WatchedStatus = "FINISHED"
WATCHING WatchedStatus = "WATCHING"
PLANNED WatchedStatus = "PLANNED"
HOLD WatchedStatus = "ONHOLD"
HOLD WatchedStatus = "HOLD"
DROPPED WatchedStatus = "DROPPED"
)
type Watched struct {
GormModel
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
Thoughts string `json:"thoughts"`
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx"`
ContentID int `json:"-" gorm:"uniqueIndex:usernctnidx"`
Content Content `json:"content"`
Activity []Activity `json:"activity"`
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
Status WatchedStatus `json:"status"`
// float so we can support decimal ratings.
// Ratings should still always be saved as out of 10.0,
// so they can be viewed with any ratings setting in the client.
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
Thoughts string `json:"thoughts"`
Pinned bool `json:"pinned" gorm:"default:false;not null"`
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
Content *Content `json:"content,omitempty"`
GameID *int `json:"-" gorm:"uniqueIndex:userngamidx"`
Game *Game `json:"game,omitempty"`
Activity []Activity `json:"activity"`
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
Tags []Tag `json:"tags,omitempty" gorm:"many2many:watched_tags;"`
// The last season that was viewed by the user for this watched entry.
// Only applies to tv shows of course.
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
}
type WatchedAddRequest struct {
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating" binding:"max=10"`
Rating float64 `json:"rating" binding:"max=10"`
Thoughts string `json:"thoughts"`
ContentID int `json:"contentId" binding:"required"`
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
// Pass a watched date and we will set the CreatedAt (and initial UpdatedAt)
// properties for this watched entry to this specific date.
WatchedDate time.Time `json:"watchedDate,omitempty"`
}
type WatchedUpdateRequest struct {
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts"`
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts"`
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts"`
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
RemoveThoughts bool `json:"removeThoughts"`
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
}
type WatchedUpdateResponse struct {
@@ -62,13 +77,33 @@ type WatchedRemoveResponse struct {
func getWatched(db *gorm.DB, userId uint) []Watched {
watched := new([]Watched)
res := db.Model(&Watched{}).Preload("Content").Preload("Activity").Preload("WatchedSeasons").Where("user_id = ?", userId).Find(&watched)
res := db.Model(&Watched{}).
Preload("Content").
Preload("Game").
Preload("Game.Poster").
Preload("Activity").
Preload("WatchedSeasons").
Preload("WatchedEpisodes").
Preload("Tags").
Where("user_id = ?", userId).
Find(&watched)
if res.Error != nil {
panic(res.Error)
}
return *watched
}
// Get a watched list item by id (must be for `userId`).
func getWatchedItemById(db *gorm.DB, userId uint, id uint) (Watched, error) {
watched := new(Watched)
res := db.Model(&Watched{}).Preload("Content").Where("user_id = ? AND id = ?", userId, id).Find(&watched)
if res.Error != nil {
slog.Error("getWatchedItemById: Failed!", "error", res.Error)
return Watched{}, res.Error
}
return *watched, nil
}
// Get another users **public** watchlist.
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
@@ -79,16 +114,16 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
// if this is a thing we need but its here.. for now at least.
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
if res.Error != nil {
slog.Error("Failed to get user for getPublicWatched request")
slog.Error("Failed to get user for getPublicWatched request", "user_id", userId)
return []Watched{}, errors.New("failed to check privacy settings")
}
if user.Private != nil && *user.Private {
slog.Error("getPublicWatched attempted to get a private list")
slog.Error("getPublicWatched attempted to get a private list", "user_id", userId)
return []Watched{}, errors.New("this watched list is private")
}
// Now we know the user is public, return their list
watched := new([]Watched)
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
if res.Error != nil {
panic(res.Error)
}
@@ -97,126 +132,10 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
var content Content
db.Where("tmdb_id = ?", ar.ContentID).Find(&content)
// Create content if not found from our db
if content == (Content{}) {
slog.Debug("Content not in db, fetching...")
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
if err != nil {
slog.Error("addWatched content tmdb api request failed", "error", err)
return Watched{}, errors.New("failed to find requested media")
}
var (
id int
title string
overview string
posterPath string
releaseDate time.Time
popularity float32
voteAverage float32
voteCount uint32
imdbID string
status string
budget uint32
revenue uint32
runtime uint32
numberOfEpisodes uint32
numberOfSeasons uint32
)
var dateFormat = "2006-01-02"
// Get details from movie/show response and fill out needed vars
if ar.ContentType == "movie" {
content := new(TMDBMovieDetails)
err = json.Unmarshal([]byte(resp), &content)
if err != nil {
slog.Error("Failed to unmarshal movie details", "error", err)
return Watched{}, errors.New("failed to process movie details response")
}
id = content.ID
overview = content.Overview
posterPath = content.PosterPath
title = content.Title
releaseDate, err = time.Parse(dateFormat, content.ReleaseDate)
if err != nil {
slog.Error("Failed to parse movie release date", "error", err)
}
popularity = content.Popularity
voteAverage = content.VoteAverage
voteCount = content.VoteCount
imdbID = content.ImdbID
status = content.Status
budget = content.Budget
revenue = content.Revenue
runtime = content.Runtime
} else {
content := new(TMDBShowDetails)
err = json.Unmarshal(resp, &content)
if err != nil {
slog.Error("Failed to unmarshal show details", "error", err)
return Watched{}, errors.New("failed to process show details response")
}
id = content.ID
overview = content.Overview
posterPath = content.PosterPath
title = content.Name
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
if err != nil {
slog.Error("Failed to parse tv release date", "error", err)
}
popularity = content.Popularity
voteAverage = content.VoteAverage
voteCount = content.VoteCount
status = content.Status
if len(content.EpisodeRunTime) > 0 {
runtime = uint32(content.EpisodeRunTime[0])
}
numberOfEpisodes = content.NumberOfEpisodes
numberOfSeasons = content.NumberOfSeasons
}
// Save the content in our db
slog.Info("Saving content to db", "id", id, "title", title)
if id == 0 || title == "" {
slog.Error("addWatched, returned content missing id or title!", "id", id, "title", title)
return Watched{}, errors.New("content response missing id or title")
}
content = Content{
TmdbID: id,
Title: title,
Overview: overview,
PosterPath: posterPath,
Type: ar.ContentType,
ReleaseDate: &releaseDate,
Popularity: popularity,
VoteAverage: voteAverage,
VoteCount: voteCount,
ImdbID: imdbID,
Status: status,
Budget: budget,
Revenue: revenue,
Runtime: runtime,
NumberOfEpisodes: numberOfEpisodes,
NumberOfSeasons: numberOfSeasons,
}
res := db.Create(&content)
if res.Error != nil {
// Error if anything but unique contraint error
if !strings.Contains(res.Error.Error(), "UNIQUE") {
slog.Error("Error creating content in database", "error", res.Error.Error())
return Watched{}, errors.New("failed to cache content in database")
}
}
// If row created, download the image
if res.RowsAffected > 0 {
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
if err != nil {
slog.Error("Failed to download content image!", "error", err.Error())
}
}
// Get content cache (or cache it if we don't have it locally)
content, err := getOrCacheContent(db, ar.ContentType, ar.ContentID)
if err != nil {
return Watched{}, err
}
// Error if content has no id
if content.ID == 0 {
@@ -224,23 +143,39 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
}
// Create watched entry in db
if ar.Status == "" {
ar.Status = WATCHING
// Set default status for when content is added by
// rating it instead of giving status first.
if ar.ContentType == "movie" {
ar.Status = FINISHED
} else {
ar.Status = WATCHING
}
}
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: &content.ID}
if ar.Thoughts != "" {
watched.Thoughts = ar.Thoughts
}
// If custom WatchedDate passed, set CreatedAt and UpdatedAt fields to it.
if !ar.WatchedDate.IsZero() {
slog.Debug("Adding watched item: The provided WatchedDate is valid.", "watched_date", ar.WatchedDate, "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
watched.CreatedAt = ar.WatchedDate
watched.UpdatedAt = ar.WatchedDate
}
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: content.ID}
res := db.Create(&watched)
if res.Error != nil {
if strings.Contains(res.Error.Error(), "UNIQUE") {
if res.Error == gorm.ErrDuplicatedKey {
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
if res.Error != nil {
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
}
if watched.DeletedAt.Time.IsZero() {
return Watched{}, errors.New("content already on watched list")
return watched, errors.New("content already on watched list")
} else {
slog.Info("addWatched: Watched list item for this content exists as soft deleted record.. attempting to restore")
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
watched.Status = ar.Status
watched.Rating = ar.Rating
watched.Thoughts = ar.Thoughts
if res.Error != nil {
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
@@ -262,7 +197,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
}
watched.Activity = append(watched.Activity, activity)
watched.Content = content
watched.Content = &content
return watched, nil
}
@@ -288,6 +223,9 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
if ar.RemoveThoughts {
upwat.Thoughts = ""
}
if ar.Pinned != nil {
upwat.Pinned = *ar.Pinned
}
res = db.Save(upwat)
if res.RowsAffected <= 0 {
return WatchedUpdateResponse{}, errors.New("no watched entry found")
@@ -308,6 +246,24 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
}
func updateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error {
slog.Debug("UpdateWatchedLastViewedSeason", "user_id", userId, "id", id, "season_num", seasonNum)
res := db.
Model(&Watched{}).
Where("id = ? AND user_id = ?", id, userId).
Update("last_viewed_season", seasonNum)
if res.Error != nil {
slog.Error("updateWatchedLastViewedSeason: Failed when updating.", "error", res.Error)
return errors.New("failed to update db")
}
if res.RowsAffected == 0 {
// likely the watched entry does not exist or is not owned by this `userId`.
slog.Error("updateWatchedLastViewedSeason: Watched entry does not exist.")
return errors.New("watched entry does not exist")
}
return nil
}
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
// Our model has a deleted_at field, which will make gorm do a soft delete.
+164
View File
@@ -0,0 +1,164 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// UniqueIndex applied between WatchedID, SeasonNum and EpisodeNum to avoid duplicates incase logic fails.
//
// Episodes on tmdb are only queried by season number + episode number, not possible via episode id,
// since episodes can be removed and re-added. For this reason we store season and episodes nums instead
// of just the episode id.
type WatchedEpisode struct {
GormModel
UserID uint `json:"-" gorm:"not null"`
User User `json:"-"`
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
EpisodeNumber int `json:"episodeNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
}
type WatchedEpisodeAddRequest struct {
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating" binding:"max=10"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
}
type WatchedEpisodeAddResponse struct {
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
AddedActivity Activity `json:"addedActivity"`
// Response from hook
EpisodeStatusChangedHookResponse EpisodeStatusChangedHookResponse `json:"episodeStatusChangedHookResponse,omitempty"`
}
// Add/edit a watched episode.
func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (WatchedEpisodeAddResponse, error) {
slog.Debug("Adding watched episode item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber, "episode", ar.EpisodeNumber)
// 1. Make sure watched item exists and it is the correct type (TV)
var w Watched
if resp := db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedEpisodes").Find(&w); resp.Error != nil {
slog.Error("Failed when adding a watched episode", "error", "failed to get watched item from db")
return WatchedEpisodeAddResponse{}, errors.New("failed when retrieving watched item")
}
if w.ID == 0 {
slog.Error("Failed when adding a watched episode", "error", "watched item does not exist in db")
return WatchedEpisodeAddResponse{}, errors.New("can't add a watched episode for a show that doesnt have a status itself")
}
if w.Content.Type != SHOW {
return WatchedEpisodeAddResponse{}, errors.New("can't add watched episode for non show content")
}
found := false
updated := false
for i, we := range w.WatchedEpisodes {
if we.SeasonNumber == ar.SeasonNumber && we.EpisodeNumber == ar.EpisodeNumber {
slog.Debug("Existing watched episode item found, updating existing")
found = true
if ar.Status != "" && ar.Status != w.WatchedEpisodes[i].Status {
w.WatchedEpisodes[i].Status = ar.Status
updated = true
}
if ar.Rating != 0 && ar.Rating != w.WatchedEpisodes[i].Rating {
w.WatchedEpisodes[i].Rating = ar.Rating
updated = true
}
break
}
}
var addedActivity Activity
if !found {
slog.Debug("Existing watched episode not found, adding as new entry")
w.WatchedEpisodes = append(w.WatchedEpisodes, WatchedEpisode{
UserID: userId,
WatchedID: ar.WatchedID,
SeasonNumber: ar.SeasonNumber,
EpisodeNumber: ar.EpisodeNumber,
Status: ar.Status,
Rating: ar.Rating,
})
}
if resp := db.Save(&w.WatchedEpisodes); resp.Error != nil {
slog.Debug("Failed to save watched episode item in db", "error", resp.Error)
return WatchedEpisodeAddResponse{}, errors.New("failed to save")
}
// Add activity
if found {
// Only add change activity if we actually updated a value
// (changing value to same value doesn't count).
if updated {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_RATING_CHANGED, Data: string(json)})
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
act := ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_ADDED, Data: string(json)}
if ar.addActivity != "" {
act.Type = ar.addActivity
}
if !ar.addActivityDate.IsZero() {
act.CustomDate = &ar.addActivityDate
}
addedActivity, _ = addActivity(db, userId, act)
}
episodeAddResp := WatchedEpisodeAddResponse{
WatchedEpisodes: w.WatchedEpisodes,
AddedActivity: addedActivity,
}
if ar.Status != "" {
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
episodeAddResp.EpisodeStatusChangedHookResponse = hookEpisodeStatusChanged(db, userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
}
return episodeAddResp, nil
}
// Remove a watched episode
func rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (Activity, error) {
slog.Debug("rmWatchedSeason called", "user_id", userId, "id", id)
var watchedEpisode WatchedEpisode
resp := db.Clauses(clause.Returning{}).Model(&WatchedEpisode{}).Unscoped().Where("id = ? AND user_id = ?", id, userId).Delete(&watchedEpisode)
if resp.Error != nil {
slog.Error("Failed when removing a watched episode", "error", resp.Error)
return Activity{}, errors.New("failed when removing watched episode")
}
if resp.RowsAffected == 0 {
slog.Error("Failed when removing a watched episode", "error", "zero rows affected")
return Activity{}, errors.New("wasn't removed from db.. may not exist")
}
slog.Debug("rmWatchedEpisode, deleted row", "row", watchedEpisode)
if watchedEpisode.ID != 0 {
json, _ := json.Marshal(map[string]interface{}{
"season": watchedEpisode.SeasonNumber,
"episode": watchedEpisode.EpisodeNumber,
"status": watchedEpisode.Status,
"rating": watchedEpisode.Rating,
})
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedEpisode.WatchedID, Type: EPISODE_REMOVED, Data: string(json)})
return addedActivity, nil
}
return Activity{}, errors.New("removed, but failed to add activity entry")
}
func getNumberOfWatchedEpisodesInSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int, acceptableStatus []WatchedStatus) (int64, error) {
var count int64
if res := db.Model(&WatchedEpisode{}).Where("user_id = ? AND watched_id = ? AND season_number = ? AND status IN ?", userId, watchedId, seasonNumber, acceptableStatus).Count(&count); res.Error != nil {
return 0, res.Error
}
return count, nil
}
+159
View File
@@ -0,0 +1,159 @@
package main
import (
"encoding/json"
"fmt"
"log/slog"
"strconv"
"gorm.io/gorm"
)
type EpisodeStatusChangedHookResponse struct {
// The watched shows status if we modified it.
NewShowStatus WatchedStatus `json:"newShowStatus,omitempty"`
// The full watched season (if created or modified).
WatchedSeason *WatchedSeason `json:"watchedSeason,omitempty"`
// All activies we have added.
AddedActivities []Activity `json:"addedActivities,omitempty"`
// All errors (fatal and non-fatal) that were encountered.
Errors []string `json:"errors,omitempty"`
}
// Called after an episode watched status has been set.
func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNum int, episodeNum int, newEpisodeStatus WatchedStatus) EpisodeStatusChangedHookResponse {
userSettings, err := userGetSettings(db, userId)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get user settings! Hook will continue.", "error", err)
} else {
if !*userSettings.AutomateShowStatuses {
slog.Debug("hookEpisodeStatusChanged: User has AutomateShowStatuses disabled. Skipping hook.", "user_id", userId)
return EpisodeStatusChangedHookResponse{}
}
}
hookResponse := EpisodeStatusChangedHookResponse{}
addHookActivity := func(aType ActivityType, data string) {
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
}
// 2. If the season (this episode is in) has no status or is planned, set season to watching.
watchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Cannot continue, failed to get watchedSeason!", "error", err)
return EpisodeStatusChangedHookResponse{Errors: []string{("failed to query db for watched season")}}
}
// If season not found, create it.
if watchedSeason == nil {
slog.Debug("hookEpisodeStatusChanged: Watched season does not exist. Creating now.")
seasonStatus := newEpisodeStatus
if newEpisodeStatus == FINISHED || newEpisodeStatus == DROPPED {
seasonStatus = WATCHING
}
resp, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
addActivity: SEASON_ADDED_AUTO,
addActivityData: map[string]interface{}{"reason": fmt.Sprintf("Episode %d was set to %s while the season had no status.", episodeNum, newEpisodeStatus)},
WatchedID: watchedId,
SeasonNumber: seasonNum,
Status: seasonStatus,
})
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to add watched season!", "error", err)
hookResponse.Errors = append(hookResponse.Errors, "failed to add watched season")
} else {
// addWatchedSeason returns all watched seasons, get the one just added. (may be best to retrofit addWatchedSeason later to return id of season/row created)
justAddedWatchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
if err != nil {
hookResponse.Errors = append(hookResponse.Errors, "failed to get newly added watched season for response")
} else {
watchedSeason = justAddedWatchedSeason
hookResponse.WatchedSeason = watchedSeason
}
hookResponse.AddedActivities = append(hookResponse.AddedActivities, resp.AddedActivity)
}
} else if watchedSeason.Status == "" || watchedSeason.Status == PLANNED ||
((newEpisodeStatus == FINISHED || newEpisodeStatus == WATCHING) && (watchedSeason.Status == HOLD || watchedSeason.Status == DROPPED)) {
reasonStr := fmt.Sprintf("Episode %d was set to %s while the season had ", episodeNum, newEpisodeStatus)
if watchedSeason.Status == "" {
reasonStr += "no status."
} else {
reasonStr += fmt.Sprintf("a status of %s.", watchedSeason.Status)
}
watchedSeason.Status = WATCHING
if res := db.Save(watchedSeason); res.Error != nil {
slog.Error("hookEpisodeStatusChanged: Failed to update season status!", "error", res.Error)
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status")
} else {
hookResponse.WatchedSeason = watchedSeason
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": watchedSeason.Status, "reason": reasonStr})
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
}
}
// 3. If the show has no status or is planned, set it to watching.
watchedShow, err := getWatchedItemById(db, userId, watchedId)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get watched show, cant continue to update show status.", "error", err)
hookResponse.Errors = append(hookResponse.Errors, "failed to get watched item for show")
return hookResponse
} else {
// Show status shouldn't be empty, but watevs, handle it just incase
if watchedShow.Status == "" || watchedShow.Status == PLANNED {
watchedShow.Status = WATCHING
if res := db.Save(watchedShow); res.Error != nil {
slog.Error("hookEpisodeStatusChanged: Failed to update show status!", "error", res.Error)
} else {
hookResponse.NewShowStatus = watchedShow.Status
json, _ := json.Marshal(map[string]interface{}{"status": watchedShow.Status, "reason": fmt.Sprintf("S%dE%d was set to %s.", seasonNum, episodeNum, newEpisodeStatus)})
addHookActivity(STATUS_CHANGED_AUTO, string(json))
}
}
}
// 4. If all episodes are FINISHED or DROPPED, set the season to FINISHED
// BUG If a seasons status is removed and the last episode of the season is marked finished,
// this will add activity for the season being marked finished, right after it is set
// to Watching just above. I think this might never happen to anyone so um ye.
tmdbIdStr := strconv.Itoa(watchedShow.Content.TmdbID)
seasonNumStr := strconv.Itoa(seasonNum)
seasonDetails, err := seasonDetails(tmdbIdStr, seasonNumStr)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get season details!", "error", err)
hookResponse.Errors = append(hookResponse.Errors, "failed to get season details for show")
return hookResponse
}
allEpisodesCount := len(seasonDetails.Episodes)
finishedEpisodesCount, err := getNumberOfWatchedEpisodesInSeason(db, userId, watchedId, seasonNum, []WatchedStatus{FINISHED, DROPPED})
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get number of watched episodes in this season!", "error", err)
hookResponse.Errors = append(hookResponse.Errors, "failed to get number of watched episodes in this season")
return hookResponse
}
slog.Debug("hookEpisodeStatusChanged: Got episode counts.", "allEpisodesCount", allEpisodesCount, "finishedEpisodesCount", finishedEpisodesCount)
if finishedEpisodesCount >= int64(allEpisodesCount) {
slog.Debug("hookEpisodeStatusChanged: All episodes have been completed (finished or dropped). Marking season finished.")
newStatus := FINISHED
if watchedSeason != nil && watchedSeason.Status == newStatus {
slog.Debug("hookEpisodeStatusChanged: WatchedSeason status is same as newStatus so not updating.")
return hookResponse
}
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNum, userId).Update("status", newStatus); res.Error != nil {
slog.Error("hookEpisodeStatusChanged: Failed to update season status to finished:", "error", res.Error.Error())
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status to finished")
return hookResponse
} else {
if watchedSeason != nil {
watchedSeason.Status = newStatus
hookResponse.WatchedSeason = watchedSeason
} else {
slog.Error("hookEpisodeStatusChanged: watchedSeason was nil HOW DID THIS HAPPEN? Anyways the client won't be able to update its state with the new season status until it is refreshed.")
}
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": newStatus, "reason": fmt.Sprintf("The season was deemed completed when episode %d was set to %s.", episodeNum, newEpisodeStatus)})
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
}
}
return hookResponse
}
+57 -17
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"log/slog"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -21,10 +22,15 @@ type WatchedSeason struct {
}
type WatchedSeasonAddRequest struct {
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating" binding:"max=10"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
// Data to add to activity if the season is created.
// Combined with data we already add.
addActivityData map[string]interface{} `json:"-"`
}
type WatchedSeasonAddResponse struct {
@@ -33,7 +39,7 @@ type WatchedSeasonAddResponse struct {
}
// Add/edit a watched season.
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at ActivityType) (WatchedSeasonAddResponse, error) {
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (WatchedSeasonAddResponse, error) {
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
// 1. Make sure watched item exists and it is the correct type (TV)
var w Watched
@@ -48,16 +54,19 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at A
if w.Content.Type != SHOW {
return WatchedSeasonAddResponse{}, errors.New("can't add watched season for non show content")
}
var found bool
found := false
updated := false
for i, ws := range w.WatchedSeasons {
if ws.SeasonNumber == ar.SeasonNumber {
slog.Debug("Existing watched season item found, updating existing")
found = true
if ar.Status != "" {
if ar.Status != "" && ar.Status != w.WatchedSeasons[i].Status {
w.WatchedSeasons[i].Status = ar.Status
updated = true
}
if ar.Rating != 0 {
if ar.Rating != 0 && ar.Rating != w.WatchedSeasons[i].Rating {
w.WatchedSeasons[i].Rating = ar.Rating
updated = true
}
break
}
@@ -79,17 +88,36 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at A
}
// Add activity
if found {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
// Only add change activity if we actually updated a value
// (changing value to same value doesn't count).
if updated {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)})
actData := map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating}
if len(ar.addActivityData) > 0 {
for k, v := range ar.addActivityData {
if _, ok := ar.addActivityData[k]; ok {
actData[k] = v
}
}
}
json, _ := json.Marshal(actData)
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
if ar.addActivity != "" {
act.Type = ar.addActivity
}
if !ar.addActivityDate.IsZero() {
act.CustomDate = &ar.addActivityDate
}
addedActivity, _ = addActivity(db, userId, act)
}
return WatchedSeasonAddResponse{
WatchedSeasons: w.WatchedSeasons,
@@ -122,3 +150,15 @@ func rmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (Activity, error)
}
return Activity{}, errors.New("removed, but failed to add activity entry")
}
func getWatchedSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int) (*WatchedSeason, error) {
var ws *WatchedSeason
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNumber, userId).Take(&ws); res.Error != nil {
slog.Error("getWatchedSeason: Failed to get:", "error", res.Error.Error())
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
return nil, nil
}
return &WatchedSeason{}, errors.New("failed to get watched season")
}
return ws, nil
}
+2
View File
@@ -3,7 +3,9 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
<meta name="viewport" content="width=device-width" />
<title>Watcharr</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
+149 -37
View File
@@ -1,10 +1,16 @@
<script lang="ts">
import type { Activity } from "@/types";
import { getOrdinalSuffix, months } from "./util/helpers";
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
import ActivityEditor from "./ActivityEditor.svelte";
import Icon from "./Icon.svelte";
import tooltip from "./actions/tooltip";
export let activity: Activity[] | undefined;
export let wListId: number;
let clickedActivity: Activity;
let groupedActivities: { [index: string]: any };
let isActivityEditorVisible: boolean;
$: {
groupedActivities = getGroupedActivity(activity);
@@ -15,68 +21,131 @@
case "ADDED_WATCHED":
if (a.data) {
const data = JSON.parse(a.data);
return `added to watched list${data?.status ? ` as ${data.status?.toLowerCase()}` : ""}${
data?.rating ? ` with ${data.rating} stars` : ""
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
data?.rating ? ` with ${data.rating} Stars` : ""
}`;
}
return "added to watched list";
return "Added to Watched List";
case "REMOVED_WATCHED":
return "removed from watched list";
return "Removed from Watched List";
case "RATING_CHANGED":
if (a.data) {
return `rating changed to ${a.data}`;
return `Rating Changed to ${a.data}`;
}
return "rating changed";
return "Rating Changed";
case "STATUS_CHANGED":
if (a.data) {
return `status changed to ${a.data?.toLowerCase()}`;
return `Status Changed to ${toFullTitleCase(a.data)}`;
}
return "status changed";
return "Status Changed";
case "STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Status Changed";
case "THOUGHTS_CHANGED":
return "thoughts changed";
return "Thoughts Changed";
case "THOUGHTS_REMOVED":
return "thoughts removed";
return "Thoughts Removed";
case "IMPORTED_WATCHED":
return "imported";
return "Imported";
case "IMPORTED_WATCHED_JF":
case "IMPORTED_WATCHED_PLEX":
return "Synced";
case "IMPORTED_RATING":
if (a.data) {
const data = JSON.parse(a.data);
if (data.rating) {
return `rating changed to ${data.rating}`;
return `Rating Changed to ${data.rating}`;
} else {
return "added to watchlist with no rating";
return "Added to Watchlist with No Rating";
}
}
return "imported rating";
return "Imported Rating";
case "IMPORTED_ADDED_WATCHED":
case "IMPORTED_ADDED_WATCHED_JF":
case "IMPORTED_ADDED_WATCHED_PLEX":
return "Imported Watch Date";
case "SEASON_ADDED":
case "SEASON_ADDED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `season ${data.season} added as ${data.status?.toLowerCase()}`;
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
}
return "season added";
return "Season Added";
case "SEASON_ADDED_JF":
case "SEASON_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
}
return "Season Synced";
case "SEASON_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `changed season ${data.season} rating to ${data.rating}`;
return `Changed Season ${data.season} Rating to ${data.rating}`;
}
return "season rating changed";
return "Season Rating Changed";
case "SEASON_STATUS_CHANGED":
case "SEASON_STATUS_CHANGED_AUTO":
if (a.data) {
const data = JSON.parse(a.data);
return `changed season ${data.season} status to ${data.status?.toLowerCase()}`;
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
}
return "season status changed";
return "Season Status Changed";
case "SEASON_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `season ${data.season} status removed`;
return `Season ${data.season} Status Removed`;
}
return "season removed";
return "Season Removed";
case "EPISODE_ADDED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Added";
case "EPISODE_ADDED_JF":
case "EPISODE_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Synced";
case "EPISODE_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
}
return "Episode Rating Changed";
case "EPISODE_STATUS_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
}
return "Episode Status Changed";
case "EPISODE_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
}
return "Episode Removed";
default:
return a.type;
}
}
function toFullTitleCase(text: string | undefined) {
if (text) {
return text
.split(" ")
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
.join(" ");
}
return "Unknown";
}
function toDayTime(d: Date) {
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
}
@@ -107,8 +176,33 @@
}
return grouped;
}
function openEditor(a: Activity) {
clickedActivity = a;
isActivityEditorVisible = true;
return;
}
function getActivityDataParsed(a: Activity) {
try {
if (a.data) {
return JSON.parse(a.data);
}
} catch (err) {
console.error("getActivityDataParsed: Failed!", err);
}
}
</script>
{#if isActivityEditorVisible}
<ActivityEditor
watchedId={wListId}
activity={clickedActivity}
activityMessage={getMsg(clickedActivity)}
onClose={() => (isActivityEditorVisible = false)}
/>
{/if}
<div class="activity">
<h2>Activity</h2>
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
@@ -119,10 +213,25 @@
{#each groupedActivities[k] as a}
{@const d = new Date(getCreatedAtVis(a))}
<li>
<span title={d.toDateString()}>
{toDayTime(d)}
</span>
<span>{getMsg(a)}</span>
<button class="unset" on:click={() => openEditor(a)}>
<span title={d.toDateString()}>{toDayTime(d)}</span>
<span>{getMsg(a)}</span>
</button>
{#if a.type?.endsWith("_AUTO")}
{@const data = getActivityDataParsed(a)}
<i
use:tooltip={{
text:
data && data.reason
? `Automated because ${data.reason}`
: "Completed by an automation.",
pos: "bot"
}}
style="width: 20px; height: 20px;"
>
<Icon i="sparkles" wh={20} />
</i>
{/if}
</li>
{/each}
{/each}
@@ -163,10 +272,19 @@
li {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
align-items: center;
button {
all: unset;
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
span {
width: max-content;
@@ -176,9 +294,8 @@
min-width: max-content;
}
&:last-child {
&:last-of-type {
background-color: $accent-color;
text-transform: capitalize;
color: $text-color;
border-radius: 8px;
padding: 10px 12px;
@@ -196,11 +313,6 @@
}
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 30px;
font-weight: bold;
margin-left: 30px;
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { updateActivity, removeActivity } from "@/lib/util/api";
import Modal from "./Modal.svelte";
import type { Activity } from "@/types";
import { notify } from "./util/notify";
export let watchedId: number;
export let activity: Activity;
export let activityMessage: string;
export let onClose: () => void;
let isDateTimeChanged: boolean;
let isDateTimeValid = true;
let currentDateObject = new Date(Date.parse(activity.customDate ?? activity.createdAt));
let currentDateString = dateToInputDateString(currentDateObject);
let currentTimeString = dateToInputTimeString(currentDateObject);
let selectedDateString = currentDateString;
let selectedTimeString = currentTimeString;
$: {
isDateTimeChanged =
currentDateString != selectedDateString || currentTimeString != selectedTimeString;
}
function dateToInputDateString(date: Date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
function dateToInputTimeString(date: Date) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function validateNewDate() {
try {
const epochMillis = Date.parse(`${selectedDateString} ${selectedTimeString}`);
const dateObj = new Date(epochMillis);
if (isNaN(dateObj.getTime())) {
isDateTimeValid = false;
return;
}
isDateTimeValid = true;
return dateObj;
} catch (err) {
console.error("ActivityEditor: validateNewDate failed!", err);
isDateTimeValid = false;
}
}
function update() {
const dateObj = validateNewDate();
if (dateObj && isDateTimeValid && isDateTimeChanged) {
updateActivity(watchedId, activity.id, dateObj);
onClose();
return;
}
notify({ text: "Unable to try updating!", type: "error" });
console.error(
"ActivityEditor: Can't try updating, data missing/invalid:",
dateObj,
isDateTimeValid,
isDateTimeChanged
);
}
function remove() {
removeActivity(watchedId, activity.id);
onClose();
}
</script>
<Modal title="Edit Activity" desc={activityMessage} maxWidth="400px" {onClose}>
<div class="centered">
<h3>Date</h3>
<input
id="activity-date"
type="date"
bind:value={selectedDateString}
on:change={validateNewDate}
class:invalid={!isDateTimeValid}
/>
<h3>Time</h3>
<input
id="activity-time"
type="time"
bind:value={selectedTimeString}
on:change={validateNewDate}
/>
<div class="button-row">
<button class="danger" on:click={remove}>Delete</button>
<div>
<button on:click={update} disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button>
</div>
</div>
</div>
</Modal>
<style lang="scss">
.centered {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
h3 {
font-size: 16px;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
}
.button-row {
display: flex;
flex-flow: row;
justify-content: space-between;
margin-top: 10px;
button {
margin-top: auto;
width: max-content;
}
}
}
</style>
+25
View File
@@ -0,0 +1,25 @@
<!-- Uses Native Color Input -->
<script lang="ts">
export let value = "#000000";
export let style = "";
</script>
<button class="plain" style={`background-color: ${value};${style}`}>
<input type="color" bind:value />
</button>
<style lang="scss">
button {
width: 100%;
height: 100%;
border-radius: 10px;
outline: 1px solid $accent-color;
cursor: pointer;
}
input {
opacity: 0;
cursor: pointer;
}
</style>

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