Compare commits

..

258 Commits

Author SHA1 Message Date
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
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
123 changed files with 9955 additions and 2458 deletions
+15 -5
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch:
env:
image: ghcr.io/sbondco/watcharr
image: sbondco/watcharr
jobs:
release-dev-image:
@@ -27,19 +27,29 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
- 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 }}
+18 -6
View File
@@ -6,7 +6,7 @@ on:
- v*
env:
image: ghcr.io/sbondco/watcharr
image: sbondco/watcharr
jobs:
release-images:
@@ -26,25 +26,37 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
- 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:
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend
FROM golang:1.21-alpine AS server
FROM golang:1.22-alpine AS server
WORKDIR /server
COPY server/*.go server/go.* ./
+11
View File
@@ -25,6 +25,7 @@ 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
@@ -53,6 +54,16 @@ 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!
+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.
+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.
+1 -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
+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

+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`.
+2 -3
View File
@@ -1,18 +1,17 @@
---
sidebar_position: 1
description: Setting up video game support.
---
# Games Support (via IGDB/Twitch)
## Overview
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.
## Steps
## Setup Steps
### Setup On Twitch
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: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.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.
+3 -3
View File
@@ -6339,9 +6339,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.15.5",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"funding": [
{
"type": "individual",
-2
View File
@@ -1,5 +1,3 @@
version: "3"
# To test prod
services:
watcharr:
-2
View File
@@ -1,5 +1,3 @@
version: "3"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
+1713 -1323
View File
File diff suppressed because it is too large Load Diff
+17 -17
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "1.35.2",
"version": "1.43.0",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -13,28 +13,28 @@
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.5.3",
"@sveltejs/adapter-node": "^5.0.1",
"@sveltejs/kit": "^2.5.24",
"@types/papaparse": "^5.3.14",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vite-pwa/sveltekit": "^0.3.0",
"@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.35.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.2.1",
"svelte": "^4.2.10",
"svelte-check": "^3.6.4",
"svelte-eslint-parser": "^0.33.1",
"svelte-preprocess": "^5.1.3",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.1.6"
"eslint-plugin-svelte": "^2.43.0",
"prettier": "^3.3.2",
"prettier-plugin-svelte": "^3.2.3",
"svelte": "^4.2.17",
"svelte-check": "^3.8.6",
"svelte-eslint-parser": "^0.41.0",
"svelte-preprocess": "^6.0.2",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"vite": "^5.2.11"
},
"type": "module",
"dependencies": {
"axios": "^1.6.7",
"axios": "^1.7.4",
"blurhash": "^2.0.5",
"papaparse": "^5.4.1"
}
+4
View File
@@ -10,11 +10,13 @@ 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"
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
@@ -25,11 +27,13 @@ var (
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"
+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
}
+36 -13
View File
@@ -40,6 +40,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.
@@ -61,7 +62,10 @@ type User struct {
// Users third party integrations (minus jellyfin for now)
UserServices []UserServices `json:"-"`
Watched []Watched
Permissions int `gorm:"default:1" json:"-"`
// All Tags
Tags []Tag `json:"-"`
// Users permissions
Permissions int `gorm:"default:1" json:"-"`
// All user settings cols, in another struct for reusability
UserSettings
}
@@ -88,6 +92,10 @@ type UserSettings struct {
// 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"`
}
// Holds third party service auth tokens for users.
@@ -147,6 +155,7 @@ type AvailableAuthProvidersResponse struct {
AvailableAuthProviders []string `json:"available"`
SignupEnabled bool `json:"signupEnabled"`
IsInSetup bool `json:"isInSetup"`
UseEmby bool `json:"useEmby"`
}
type ArgonParams struct {
@@ -254,6 +263,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.")
@@ -275,6 +299,8 @@ 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
@@ -406,6 +432,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 {
@@ -447,28 +474,28 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
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.")
if err := plexUserHasAccessToPlexHost(lr.AuthToken); err != nil {
slog.Error("loginPlex: Cannot register Plex user. Failed to verify they have access to our home plex server.", "error", err)
return AuthResponse{}, errors.New("failed to verify plex access")
}
dbUser.Username = account.Username
dbUser.Type = PLEX_USER
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
if err != nil {
slog.Error("loginPlex: Failed to get home server auth token for the new user! User will still be created, a re-login may fix this issue.", "error", err)
}
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)
@@ -480,10 +507,6 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
}
} else {
// If user exists.. update their access tokens in db
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
if err != nil {
slog.Error("loginPlex: Failed to get home server auth token!", "error", err)
}
for i, v := range dbUser.UserServices {
if v.Name == "plex" {
slog.Info("loginPlex: Found plex user service.. attemping to update")
+36 -15
View File
@@ -6,23 +6,37 @@ import (
"log"
"log/slog"
"os"
"path"
"time"
"github.com/sbondCo/Watcharr/game"
"gorm.io/gorm"
)
var DataPath = "./data"
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
@@ -44,6 +58,9 @@ type ServerConfig struct {
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
// of failure.
@@ -60,7 +77,9 @@ type ServerConfig struct {
func (c *ServerConfig) GetSafe() ServerConfig {
return ServerConfig{
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,
@@ -82,7 +101,7 @@ var (
// 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.")
@@ -119,14 +138,15 @@ func generateConfig() error {
cfg := ServerConfig{
JWT_SECRET: key,
// Other defaults..
SIGNUP_ENABLED: true,
DEFAULT_COUNTRY: "US",
SIGNUP_ENABLED: true,
}
barej, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
return err
}
Config = cfg
return os.WriteFile("./data/watcharr.json", barej, 0755)
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
}
// Update server config property
@@ -137,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" {
@@ -144,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")
}
@@ -156,7 +180,7 @@ 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 {
@@ -173,16 +197,13 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
f.Games = true
}
// 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 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
}
+127 -4
View File
@@ -1,11 +1,14 @@
package main
import (
"encoding/json"
"errors"
"log/slog"
"path"
"strconv"
"time"
"github.com/gin-contrib/cache/persistence"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
@@ -17,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"`
@@ -85,7 +90,7 @@ func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
// 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("./data/img", c.PosterPath))
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())
}
@@ -170,6 +175,50 @@ func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (
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.
@@ -194,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")
@@ -204,6 +256,54 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
return *resp, nil
}
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)
@@ -248,13 +348,26 @@ func tvCredits(id string) (TMDBContentCredits, error) {
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
}
@@ -331,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
}
+2 -2
View File
@@ -175,7 +175,7 @@ func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, a
res := db.Create(&watched)
if res.Error != nil {
if res.Error == gorm.ErrDuplicatedKey {
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
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")
}
@@ -183,7 +183,7 @@ func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, a
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 content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
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 {
+24 -20
View File
@@ -1,52 +1,56 @@
module github.com/sbondCo/Watcharr
go 1.21
go 1.22
require (
github.com/buckket/go-blurhash v1.1.0
github.com/gin-contrib/cache v1.2.0
github.com/gin-contrib/cors v1.7.0
github.com/gin-gonic/gin v1.9.1
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.21.0
golang.org/x/crypto v0.26.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.7
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
)
require (
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bytedance/sonic v1.11.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // 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.19.0 // 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.7 // indirect
github.com/kr/text v0.2.0 // 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.17 // 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.1 // 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.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.33.0 // 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.23.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+53 -43
View File
@@ -2,54 +2,56 @@ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8b
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
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.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.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
github.com/bytedance/sonic v1.11.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/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
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.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
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.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.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.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
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.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.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=
@@ -64,8 +66,8 @@ 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.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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=
@@ -73,10 +75,12 @@ 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.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
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.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
@@ -84,32 +88,38 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
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.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.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/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.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.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-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -118,9 +128,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
gorm.io/gorm v1.25.7/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.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
gorm.io/gorm v1.25.11/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=
+67 -9
View File
@@ -27,14 +27,17 @@ 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"`
Status WatchedStatus `json:"status"`
Thoughts string `json:"thoughts"`
DatesWatched []time.Time `json:"datesWatched"`
Name string `json:"name"`
TmdbID int `json:"tmdbId"`
Type ContentType `json:"type"`
Rating int8 `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"`
}
type ImportResponse struct {
@@ -67,7 +70,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")
@@ -170,5 +174,59 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
}
}
}
// 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
}
}
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 = int8(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
}
+23 -5
View File
@@ -38,10 +38,12 @@ 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 {
return "", err
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 {
@@ -56,6 +58,22 @@ func addJob(name string, userId uint) (string, error) {
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]
@@ -91,11 +109,11 @@ func updateJobStatus(id string, userId uint, status JobStatus) error {
}
j.Status = status
// If job is set to done, remove it after 1 minute.
if status == JOB_DONE {
slog.Debug("updateJobStatus: Job set to done. Will be removed after 1m.", "id", id)
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(1 * time.Minute)
slog.Debug("updateJobStatus: Job done. waited 1m.. removing job now.", "id", id)
time.Sleep(30 * time.Minute)
slog.Debug("updateJobStatus: Job done. waited 30m.. removing job now.", "id", id)
rmJob(id, userId)
}()
}
+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()
}
}
+11 -101
View File
@@ -2,7 +2,6 @@ package main
import (
"encoding/json"
"encoding/xml"
"errors"
"io"
"log/slog"
@@ -39,50 +38,6 @@ type PlexHostConfigUpdateResponse struct {
PLEX_MACHINE_ID string
}
type PlexUsersResponse struct {
XMLName xml.Name `xml:"MediaContainer"`
Text string `xml:",chardata"`
FriendlyName string `xml:"friendlyName,attr"`
Identifier string `xml:"identifier,attr"`
MachineIdentifier string `xml:"machineIdentifier,attr"`
TotalSize string `xml:"totalSize,attr"`
Size string `xml:"size,attr"`
User []struct {
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
Title string `xml:"title,attr"`
Username string `xml:"username,attr"`
Email string `xml:"email,attr"`
RecommendationsPlaylistId string `xml:"recommendationsPlaylistId,attr"`
Thumb string `xml:"thumb,attr"`
Protected string `xml:"protected,attr"`
Home string `xml:"home,attr"`
AllowTuners string `xml:"allowTuners,attr"`
AllowSync string `xml:"allowSync,attr"`
AllowCameraUpload string `xml:"allowCameraUpload,attr"`
AllowChannels string `xml:"allowChannels,attr"`
AllowSubtitleAdmin string `xml:"allowSubtitleAdmin,attr"`
FilterAll string `xml:"filterAll,attr"`
FilterMovies string `xml:"filterMovies,attr"`
FilterMusic string `xml:"filterMusic,attr"`
FilterPhotos string `xml:"filterPhotos,attr"`
FilterTelevision string `xml:"filterTelevision,attr"`
Restricted string `xml:"restricted,attr"`
Server []struct {
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
ServerId string `xml:"serverId,attr"`
MachineIdentifier string `xml:"machineIdentifier,attr"`
Name string `xml:"name,attr"`
LastSeenAt string `xml:"lastSeenAt,attr"`
NumLibraries string `xml:"numLibraries,attr"`
AllLibraries string `xml:"allLibraries,attr"`
Owned string `xml:"owned,attr"`
Pending string `xml:"pending,attr"`
} `xml:"Server"`
} `xml:"User"`
}
// Plex get libraries response
// /library/sections
type PlexLibrariesResponse struct {
@@ -136,8 +91,8 @@ type PlexLibraryItemsResponse struct {
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
ViewMode int `json:"viewMode"`
Metadata []struct {
// 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
@@ -238,8 +193,8 @@ type PlexLibraryItemSeasonsResponse struct {
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
ViewMode int `json:"viewMode"`
Directory []struct {
// 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"`
@@ -301,8 +256,8 @@ type PlexLibraryItemEpisodesResponse struct {
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
ViewMode int `json:"viewMode"`
Metadata []struct {
// 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"`
@@ -454,7 +409,6 @@ func fetchPlexAccountFromToken(token string) (PlexUser, error) {
if err != nil {
return PlexUser{}, err
}
slog.Info(string(body))
defer resp.Body.Close()
var pa PlexAccountResponse
err = json.Unmarshal(body, &pa)
@@ -488,54 +442,6 @@ func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
}
// If a plex user has access to our home plex server (PLEX_HOST).
func plexUserHasAccessToPlexHost(token string) error {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", "https://plex.tv/api/users", nil)
if err != nil {
return err
}
req.Header.Set("X-Plex-Token", token)
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 pa PlexUsersResponse
err = xml.Unmarshal(body, &pa)
if err != nil {
return err
}
if len(pa.User) <= 0 {
return errors.New("found no users in response")
}
// Now check if any of the users servers include our home server machine id
homeServerFound := false
userLoop:
for _, user := range pa.User {
for _, server := range user.Server {
if server.MachineIdentifier == Config.PLEX_MACHINE_ID {
slog.Debug("plexUserHasAccessToPlexHost: Processing a server.", "server", server.MachineIdentifier)
homeServerFound = true
break userLoop
}
}
if homeServerFound {
break
}
}
if homeServerFound {
return nil
}
return errors.New("user does not have access to home plex server")
}
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
@@ -638,6 +544,7 @@ func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryI
// 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)
@@ -666,11 +573,14 @@ func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, e
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
if v.AccessToken == "" {
slog.Warn("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
slog.Error("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
continue
}
authToken = v.AccessToken
}
}
if authToken == "" {
slog.Error("getPlexHomeServerAuthToken: No authToken retrieved!")
}
return authToken, nil
}
+530 -48
View File
@@ -79,13 +79,94 @@ func (b *BaseRouter) addContentRoutes() {
exp := time.Hour * 24
// Search for content
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
// println(c.Param("query"))
content.GET("/search/multi/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
c.Status(400)
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
return
}
content, err := searchContent(c.Param("query"))
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchContent(c.Param("query"), pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Search for movies
content.GET("/search/movie/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
return
}
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchMovies(c.Param("query"), pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Search for shows
content.GET("/search/tv/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
return
}
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchTv(c.Param("query"), pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Search for people
content.GET("/search/person/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("query") == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
return
}
pageQ := c.Query("page")
pageNum := 1
if pageQ != "" {
num, err := strconv.Atoi(pageQ)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
return
}
pageNum = num
}
content, err := searchPeople(c.Param("query"), pageNum)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
@@ -94,7 +175,7 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Get movie details (for movie page)
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/movie/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -122,7 +203,7 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Get tv details (for tv page)
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -150,7 +231,8 @@ func (b *BaseRouter) addContentRoutes() {
}))
// Get season details
content.GET("/tv/:id/season/:num", cache.CachePage(b.ms, exp, func(c *gin.Context) {
// Supports `watchedId` query parameter for saving the requested season as `LastViewedSeason`.
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
if c.Param("id") == "" || c.Param("num") == "" {
c.Status(400)
return
@@ -160,8 +242,30 @@ func (b *BaseRouter) addContentRoutes() {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
// If a `watchedId` is passed, we should update it with this season
// number, so the LastViewedSeason field is up to date (this seemed
// better than making a new request for just saving this).
// We will attach a `watcharr-lastviewedseason-saved` header if
// this part succeeds so the client can decide on showing an error.
if watchedIdQ := c.Query("watchedId"); watchedIdQ != "" {
userId := c.MustGet("userId").(uint)
watchedId, err := strconv.ParseUint(watchedIdQ, 10, 64)
if err != nil {
slog.Error("get season details route: Processing watchedId param failed", "error", err.Error(), "id", watchedIdQ)
} else {
if seasonNum, err := strconv.ParseInt(c.Param("num"), 10, 64); err == nil {
if err = updateWatchedLastViewedSeason(b.db, userId, uint(watchedId), int(seasonNum)); err == nil {
c.Header("watcharr-lastviewedseason-saved", "1")
}
} else {
slog.Error("get season details route: Parsing season number as int failed", "error", err.Error(), "season_num", c.Param("num"))
}
}
} else {
slog.Debug("get season details route: No watchedId parameter found.. not doing anything.")
}
c.JSON(http.StatusOK, content)
}))
})
// Get person details
content.GET("/person/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
@@ -240,6 +344,16 @@ func (b *BaseRouter) addContentRoutes() {
}
c.JSON(http.StatusOK, content)
}))
// Available regions for watch providers
content.GET("/regions", func(c *gin.Context) {
r, err := regions()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, r)
})
}
func (b *BaseRouter) addGameRoutes() {
@@ -388,12 +502,8 @@ func (b *BaseRouter) addWatchedRoutes() {
watched.DELETE(":id", func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.Status(400)
return
}
userId := c.MustGet("userId").(uint)
if err == nil {
userId := c.MustGet("userId").(uint)
response, err := removeWatched(b.db, userId, uint(id))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
@@ -466,6 +576,50 @@ func (b *BaseRouter) addWatchedRoutes() {
}
c.JSON(http.StatusOK, response)
})
watched.POST(":id/tag/:tagId", func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
slog.Error("tag watched route failed to convert id param to int", "error", err)
c.Status(http.StatusBadRequest)
return
}
tagId, err := strconv.Atoi(c.Param("tagId"))
if err != nil {
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
c.Status(http.StatusBadRequest)
return
}
userId := c.MustGet("userId").(uint)
err = addWatchedToTag(b.db, userId, uint(tagId), uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
})
watched.DELETE(":id/tag/:tagId", func(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
slog.Error("tag watched route failed to convert id param to int", "error", err)
c.Status(http.StatusBadRequest)
return
}
tagId, err := strconv.Atoi(c.Param("tagId"))
if err != nil {
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
c.Status(http.StatusBadRequest)
return
}
userId := c.MustGet("userId").(uint)
err = rmWatchedFromTag(b.db, userId, uint(tagId), uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
})
}
func (b *BaseRouter) addActivityRoutes() {
@@ -616,6 +770,7 @@ func (b *BaseRouter) addAuthRoutes() {
AvailableAuthProviders: availableAuthProviders,
SignupEnabled: Config.SIGNUP_ENABLED,
IsInSetup: ServerInSetup,
UseEmby: Config.USE_EMBY,
})
})
@@ -914,6 +1069,22 @@ func (b *BaseRouter) addImportRoutes() {
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
imprt.POST("/trakt", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var ar TraktImportRequest
err := c.ShouldBindJSON(&ar)
if err == nil {
response, err := traktImportWatched(b.db, userId, ar.Username)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
}
func (b *BaseRouter) addServerRoutes() {
@@ -961,6 +1132,38 @@ func (b *BaseRouter) addServerRoutes() {
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
c.JSON(http.StatusOK, getServerStats(b.db))
}))
// Get all server users (for manage users page)
server.GET("/users", func(c *gin.Context) {
resp, err := getAllUsers(b.db)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, resp)
})
// Edit a user (for manage users page)
server.POST("/users/:id", func(c *gin.Context) {
userId, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
slog.Error("/users/:id failed to parse id as a uint", "error", err)
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to parse id"})
return
}
var ur UpdateUserRequest
err = c.ShouldBindJSON(&ur)
if err == nil {
err := manageUser(b.db, uint(userId), ur)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
}
func (b *BaseRouter) addFeatureRoutes() {
@@ -973,10 +1176,12 @@ func (b *BaseRouter) addFeatureRoutes() {
}
func (b *BaseRouter) addSonarrRoutes() {
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db), AdminRequired())
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db))
// Routes are manually given AdminRequired or PermRequired middleware.
// Test configuration
s.POST("/test", func(c *gin.Context) {
s.POST("/test", AdminRequired(), func(c *gin.Context) {
var ur ArrTestParams
err := c.ShouldBindJSON(&ur)
if err == nil {
@@ -992,7 +1197,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})
// Used to get config for specific server (quality profile, root folder, etc)
s.GET("/config/:name", func(c *gin.Context) {
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
server, err := getSonarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
@@ -1007,7 +1212,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})
// Add sonarr server into config
s.POST("/add", func(c *gin.Context) {
s.POST("/add", AdminRequired(), func(c *gin.Context) {
var ur SonarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
@@ -1023,7 +1228,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})
// Edit sonarr servers config
s.POST("/edit", func(c *gin.Context) {
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
var ur SonarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
@@ -1039,7 +1244,7 @@ func (b *BaseRouter) addSonarrRoutes() {
})
// Remove sonarr server
s.POST("/rm/:name", func(c *gin.Context) {
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
err := rmSonarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
@@ -1049,40 +1254,105 @@ func (b *BaseRouter) addSonarrRoutes() {
})
// Get safe config for all sonarr servers
s.GET("", func(c *gin.Context) {
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
response := getSonarrsSafe()
c.JSON(http.StatusOK, response)
})
// Request a show
s.POST("/request", func(c *gin.Context) {
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
var ur arr.SonarrRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
server, err := getSonarr(ur.ServerName)
userId := c.MustGet("userId").(uint)
perms := c.GetInt("userPermissions")
response, err := createSonarrRequest(b.db, userId, perms, ur)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
ur.AutomaticSearch = server.AutomaticSearch
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
err = sonarr.AddContent(sonarr.BuildAddShowBody(ur))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
s.GET("/request/:tmdbId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
if err != nil {
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
c.Status(400)
return
}
response, err := getArrRequestByTmdbId(b.db, SHOW, tmdbId)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
s.POST("/request/approve/:id", PermRequired(PERM_ADMIN), func(c *gin.Context) {
var ur arr.SonarrRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
requestId, err := strconv.Atoi(c.Param("id"))
if err != nil {
slog.Error("Couldn't parse request id", "request_id", requestId)
c.Status(400)
return
}
response, err := approveSonarrRequest(b.db, uint(requestId), ur)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
s.GET("/status/:serverName/:arrId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
response, err := getSonarrQueueDetails(c.Param("serverName"), c.Param("arrId"))
if err != nil {
if err.Error() == "no details found" {
c.Status(http.StatusNoContent) // Item not found in queue.. missing
return
}
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
s.GET("/info/:requestId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
if err != nil {
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
c.Status(http.StatusBadRequest)
return
}
response, err := getSonarrRequestInfo(b.db, uint(requestId))
if err != nil {
if err.Error() == "request deleted" {
c.JSON(http.StatusNotFound, ErrorResponse{Error: "request deleted"})
return
}
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addRadarrRoutes() {
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db), AdminRequired())
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db))
// Routes are manually given AdminRequired or PermRequired middleware.
// Test configuration
s.POST("/test", func(c *gin.Context) {
s.POST("/test", AdminRequired(), func(c *gin.Context) {
var ur ArrTestParams
err := c.ShouldBindJSON(&ur)
if err == nil {
@@ -1098,7 +1368,7 @@ func (b *BaseRouter) addRadarrRoutes() {
})
// Get config for specific server
s.GET("/config/:name", func(c *gin.Context) {
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
server, err := getRadarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
@@ -1112,7 +1382,7 @@ func (b *BaseRouter) addRadarrRoutes() {
c.JSON(http.StatusOK, resp)
})
s.POST("/add", func(c *gin.Context) {
s.POST("/add", AdminRequired(), func(c *gin.Context) {
var ur RadarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
@@ -1127,7 +1397,7 @@ func (b *BaseRouter) addRadarrRoutes() {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
s.POST("/edit", func(c *gin.Context) {
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
var ur RadarrSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
@@ -1142,7 +1412,7 @@ func (b *BaseRouter) addRadarrRoutes() {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
s.POST("/rm/:name", func(c *gin.Context) {
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
err := rmRadarr(c.Param("name"))
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
@@ -1151,27 +1421,165 @@ func (b *BaseRouter) addRadarrRoutes() {
c.Status(http.StatusOK)
})
s.GET("", func(c *gin.Context) {
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
response := getRadarrsSafe()
c.JSON(http.StatusOK, response)
})
s.POST("/request", func(c *gin.Context) {
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
var ur arr.RadarrRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
server, err := getRadarr(ur.ServerName)
userId := c.MustGet("userId").(uint)
perms := c.GetInt("userPermissions")
response, err := createRadarrRequest(b.db, userId, perms, ur)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
ur.AutomaticSearch = server.AutomaticSearch
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
err = radarr.AddContent(radarr.BuildAddMovieBody(ur))
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
s.GET("/request/:tmdbId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
if err != nil {
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
c.Status(400)
return
}
response, err := getArrRequestByTmdbId(b.db, MOVIE, tmdbId)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
s.POST("/request/approve/:id", PermRequired(PERM_ADMIN), func(c *gin.Context) {
var ur arr.RadarrRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
requestId, err := strconv.Atoi(c.Param("id"))
if err != nil {
slog.Error("Couldn't parse request id", "request_id", requestId)
c.Status(400)
return
}
response, err := approveRadarrRequest(b.db, uint(requestId), ur)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
s.GET("/status/:serverName/:arrId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
response, err := getRadarrQueueDetails(c.Param("serverName"), c.Param("arrId"))
if err != nil {
if err.Error() == "no details found" {
c.Status(http.StatusNoContent) // Item not found in queue.. missing
return
}
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
s.GET("/info/:requestId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
if err != nil {
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
c.Status(http.StatusBadRequest)
return
}
response, err := getRadarrRequestInfo(b.db, uint(requestId))
if err != nil {
if err.Error() == "request deleted" {
c.JSON(http.StatusNotFound, ErrorResponse{Error: "request deleted"})
return
}
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addArrRequestRoutes() {
s := b.rg.Group("/arr/request").Use(AuthRequired(b.db))
// Get all requests (for manage_requests view), only for admins.
s.GET("/", AdminRequired(), func(c *gin.Context) {
response, err := getArrRequests(b.db)
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Deny a request (for manage_requests view), only for admins.
s.POST("/deny/:id", AdminRequired(), func(c *gin.Context) {
requestId, err := strconv.Atoi(c.Param("id"))
if err != nil {
slog.Error("Couldn't parse request id", "request_id", requestId)
c.Status(400)
return
}
err = denyArrRequest(b.db, uint(requestId))
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
})
}
func (b *BaseRouter) addJobRoutes() {
job := b.rg.Group("/job").Use(AuthRequired(nil))
// Uses wildcard so it still works in cases where the job id includes a /.
// (yes i changed this instead of not allowing a / when we generate a job id becuz easier)
job.GET("/*id", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
// When we get id param, don't include first letter, which will be the beginning '/'.
response, err := getJob(c.Param("id")[1:], userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, *response)
})
}
func (b *BaseRouter) addTaskRoutes() {
task := b.rg.Group("/task").Use(AuthRequired(b.db), AdminRequired())
task.GET("/", func(c *gin.Context) {
response := getAllTasks()
c.JSON(http.StatusOK, response)
})
task.PUT(":name", func(c *gin.Context) {
if c.Param("name") == "" {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "no task name provided"})
return
}
var rr TaskRescheduleRequest
err := c.ShouldBindJSON(&rr)
if err == nil {
err := rescheduleTask(c.Param("name"), rr)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
return
}
@@ -1179,16 +1587,90 @@ func (b *BaseRouter) addRadarrRoutes() {
})
}
func (b *BaseRouter) addJobRoutes() {
job := b.rg.Group("/job").Use(AuthRequired(nil))
func (b *BaseRouter) addTagRoutes() {
tag := b.rg.Group("/tag").Use(AuthRequired(nil))
job.GET("/:id", func(c *gin.Context) {
// Get list of all our tags.
tag.GET("", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
response, err := getJob(c.Param("id"), userId)
tags, err := getTags(b.db, userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, *response)
c.JSON(http.StatusOK, tags)
})
// // Get all items within one of our tags.
// tag.GET(":id", func(c *gin.Context) {
// id, err := strconv.Atoi(c.Param("id"))
// if err != nil {
// slog.Error("getTag route failed to convert id param to int", "error", err)
// c.Status(http.StatusBadRequest)
// return
// }
// userId := c.MustGet("userId").(uint)
// tags, err := getTag(b.db, userId, uint(id))
// if err != nil {
// c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
// return
// }
// c.JSON(http.StatusOK, tags)
// })
// Create a tag.
tag.POST("", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var tr TagAddRequest
err := c.ShouldBindJSON(&tr)
if err == nil {
response, err := addTag(b.db, userId, tr)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
// Update a tag.
tag.PUT(":id", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Status(400)
slog.Error("tag update rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
return
}
var tr TagAddRequest
err = c.ShouldBindJSON(&tr)
if err == nil {
err := updateTag(b.db, userId, uint(id), tr)
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
tag.DELETE(":id", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Status(400)
slog.Error("tag delete rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
return
}
err = deleteTag(b.db, userId, uint(id))
if err != nil {
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
})
}
+176
View File
@@ -0,0 +1,176 @@
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
// }
// 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
}
+159 -21
View File
@@ -1,35 +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) {
go setupMinutelyTasks(db)
go setupDailyTasks(db)
}
func setupMinutelyTasks(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
func setupDailyTasks(db *gorm.DB) {
taskRunInterval := 24 * time.Hour
ticker := time.NewTicker(taskRunInterval)
defer ticker.Stop()
for range ticker.C {
cleanupImages(db)
// 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
+10
View File
@@ -58,12 +58,20 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
if ur.IncludePreviouslyWatched != nil {
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
}
if ur.AutomateShowStatuses != nil {
user.AutomateShowStatuses = ur.AutomateShowStatuses
}
if ur.Country != nil {
user.Country = ur.Country
}
db.Save(&user)
return UserSettings{
Private: user.Private,
PrivateThoughts: user.PrivateThoughts,
HideSpoilers: user.HideSpoilers,
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
AutomateShowStatuses: user.AutomateShowStatuses,
Country: user.Country,
}, nil
}
@@ -80,6 +88,8 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
PrivateThoughts: user.PrivateThoughts,
HideSpoilers: user.HideSpoilers,
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
AutomateShowStatuses: user.AutomateShowStatuses,
Country: user.Country,
}, 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
}
+25 -7
View File
@@ -52,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)
}
@@ -64,7 +64,7 @@ func main() {
isProd = false
}
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{TranslateError: true})
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)
}
@@ -81,6 +81,8 @@ func main() {
&Follow{},
&Image{},
&Game{},
&ArrRequest{},
&Tag{},
)
if err != nil {
log.Fatal("Failed to auto migrate database:", err)
@@ -93,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,
}))
@@ -140,7 +155,10 @@ func main() {
br.addFeatureRoutes()
br.addSonarrRoutes()
br.addRadarrRoutes()
br.addArrRequestRoutes()
br.addJobRoutes()
br.addTaskRoutes()
br.addTagRoutes()
br.rg.Static("/img", path.Join(DataPath, "img"))
go setupTasks(db)
@@ -152,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
+59 -47
View File
@@ -21,7 +21,7 @@ const (
FINISHED WatchedStatus = "FINISHED"
WATCHING WatchedStatus = "WATCHING"
PLANNED WatchedStatus = "PLANNED"
HOLD WatchedStatus = "ONHOLD"
HOLD WatchedStatus = "HOLD"
DROPPED WatchedStatus = "DROPPED"
)
@@ -30,6 +30,7 @@ type Watched struct {
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
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"`
@@ -38,6 +39,10 @@ type Watched struct {
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 {
@@ -52,10 +57,11 @@ type WatchedAddRequest struct {
}
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 int8 `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 {
@@ -68,13 +74,33 @@ type WatchedRemoveResponse struct {
func getWatched(db *gorm.DB, userId uint) []Watched {
watched := new([]Watched)
res := db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Preload("WatchedSeasons").Preload("WatchedEpisodes").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)
@@ -85,16 +111,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").Preload("Game").Preload("Game.Poster").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)
}
@@ -103,45 +129,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("type = ? AND tmdb_id = ?", ar.ContentType, 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")
}
if ar.ContentType == "movie" {
c := new(TMDBMovieDetails)
err := json.Unmarshal([]byte(resp), &c)
if err != nil {
slog.Error("Failed to unmarshal movie details", "error", err)
return Watched{}, errors.New("failed to process movie details response")
}
content, err = cacheContentMovie(db, *c, false)
if err != nil {
slog.Error("addWatched failed to cache movie content", "content_id", ar.ContentID, "err", err)
return Watched{}, 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 Watched{}, errors.New("failed to process tv details response")
}
content, err = cacheContentTv(db, *c, false)
if err != nil {
slog.Error("addWatched failed to cache tv content", "content_id", ar.ContentID, "err", err)
return Watched{}, errors.New("failed to cache content")
}
}
// 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 {
@@ -229,6 +220,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")
@@ -249,6 +243,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.
+18 -3
View File
@@ -31,7 +31,7 @@ type WatchedEpisodeAddRequest struct {
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
Rating int8 `json:"rating" binding:"max=10"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
}
@@ -39,6 +39,8 @@ type WatchedEpisodeAddRequest struct {
type WatchedEpisodeAddResponse struct {
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
AddedActivity Activity `json:"addedActivity"`
// Response from hook
EpisodeStatusChangedHookResponse EpisodeStatusChangedHookResponse `json:"episodeStatusChangedHookResponse,omitempty"`
}
// Add/edit a watched episode.
@@ -115,10 +117,15 @@ func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (
}
addedActivity, _ = addActivity(db, userId, act)
}
return WatchedEpisodeAddResponse{
episodeAddResp := WatchedEpisodeAddResponse{
WatchedEpisodes: w.WatchedEpisodes,
AddedActivity: addedActivity,
}, nil
}
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
@@ -147,3 +154,11 @@ func rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (Activity, error) {
}
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
}
+25 -2
View File
@@ -25,9 +25,12 @@ type WatchedSeasonAddRequest struct {
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
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 {
@@ -98,7 +101,15 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
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
@@ -139,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
}
+41 -6
View File
@@ -2,6 +2,8 @@
import type { Activity } from "@/types";
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;
@@ -36,6 +38,12 @@
return `Status Changed to ${toFullTitleCase(a.data)}`;
}
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";
case "THOUGHTS_REMOVED":
@@ -60,6 +68,7 @@
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 ${toFullTitleCase(data.status)}`;
@@ -79,6 +88,7 @@
}
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 ${toFullTitleCase(data.status)}`;
@@ -172,6 +182,16 @@
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}
@@ -197,6 +217,21 @@
<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}
@@ -235,6 +270,11 @@
}
li {
display: flex;
flex-flow: row;
gap: 8px;
align-items: center;
button {
all: unset;
display: flex;
@@ -254,7 +294,7 @@
min-width: max-content;
}
&:last-child {
&:last-of-type {
background-color: $accent-color;
color: $text-color;
border-radius: 8px;
@@ -273,11 +313,6 @@
}
h2 {
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 30px;
font-weight: bold;
margin-left: 30px;
+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>
+47 -2
View File
@@ -1,8 +1,11 @@
<script lang="ts">
import type { DropDownItem } from "@/types";
import Icon from "./Icon.svelte";
import { onMount } from "svelte";
export let options: string[] | DropDownItem[];
// If we are using DropDownItems[] as options.
export let isDropDownItem = false;
export let active: string | number | undefined = undefined;
export let placeholder: string;
export let blendIn: boolean = false;
@@ -11,15 +14,54 @@
let activeValue: string;
let open = false;
let ulElement: HTMLUListElement;
let mainElement: HTMLDivElement;
function handleKeyPress(event: KeyboardEvent) {
if (!open || disabled) return; // Don't handle if closed or disabled
const pressedLetter = event.key.toLowerCase();
// Filter options that start with the pressed letter (ignoring case)
const filteredOptions = options.filter((o) =>
typeof o === "string"
? o.toLowerCase().startsWith(pressedLetter)
: o.value.toLowerCase().startsWith(pressedLetter)
);
// If there are filtered options, select the first one
let f: string | number | undefined;
if (filteredOptions.length > 0) {
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
}
// Find first button with text content starting with letter pressed and scroll it into view.
const btns = ulElement?.querySelectorAll("button");
for (let i = 0; i < btns.length; i++) {
const btn = btns[i];
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
btn.scrollIntoView({ behavior: "smooth", block: "start" });
break;
}
}
}
$: {
if (typeof active === "string") {
if (typeof active === "string" && !isDropDownItem) {
activeValue = active;
} else {
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
if (v && typeof v !== "string") activeValue = v.value;
}
}
onMount(() => {
mainElement.addEventListener("keypress", handleKeyPress);
return () => {
mainElement.removeEventListener("keypress", handleKeyPress);
};
});
</script>
<div
@@ -28,12 +70,13 @@
typeof active === "undefined" ? "placeholder-shown" : "",
blendIn ? "blend-in" : ""
].join(" ")}
bind:this={mainElement}
>
<button on:click={() => (open = !open)} {disabled}>
{activeValue ? activeValue : placeholder}
<Icon i="chevron" facing={open ? "up" : "down"} />
</button>
<ul>
<ul bind:this={ulElement}>
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
<li>
<button
@@ -111,6 +154,8 @@
border-bottom-right-radius: 5px;
background-color: $bg-color;
z-index: 99;
max-height: 20vh;
overflow-y: auto;
li {
width: 100%;
+1 -1
View File
@@ -60,7 +60,7 @@
{#if isDragOver}
Import {text}
{:else}
Browse For {text}
{text}
{/if}
</h4>
</div>
+4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
export let pretty: string;
export let error: any;
export let onRetry: () => void | undefined = undefined!;
</script>
<div>
@@ -14,6 +15,9 @@
{:else}
<p>{JSON.stringify(error)}</p>
{/if}
{#if onRetry}
<button on:click={onRetry}>Try Again</button>
{/if}
</div>
</div>
+135 -26
View File
@@ -58,34 +58,27 @@
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
/>
</svg>
{:else if i === "emby"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
<path
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"
/>
</svg>
{:else if i === "plex"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
</svg>
{:else if i === "trash"}
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
<svg
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 512 512"
fill="currentColor"
>
<path d="M296 64h-80a7.91 7.91 0 00-8 8v24h96V72a7.91 7.91 0 00-8-8z" fill="none" />
<path
d="M112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-miterlimit="10"
stroke-width="32"
d="M80 112h352"
/>
<path
d="M192 112V72h0a23.93 23.93 0 0124-24h80a23.93 23.93 0 0124 24h0v40M256 176v224M184 176l8 224M328 176l-8 224"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32"
d="M432 96h-96V72a40 40 0 00-40-40h-80a40 40 0 00-40 40v24H80a16 16 0 000 32h17l19 304.92c1.42 26.85 22 47.08 48 47.08h184c26.13 0 46.3-19.78 48-47l19-305h17a16 16 0 000-32zM192.57 416H192a16 16 0 01-16-15.43l-8-224a16 16 0 1132-1.14l8 224A16 16 0 01192.57 416zM272 400a16 16 0 01-32 0V176a16 16 0 0132 0zm32-304h-96V72a7.91 7.91 0 018-8h80a7.91 7.91 0 018 8zm32 304.57A16 16 0 01320 416h-.58A16 16 0 01304 399.43l8-224a16 16 0 1132 1.14z"
/>
</svg>
{:else if i === "close"}
@@ -100,9 +93,9 @@
/>
</svg>
{:else if i === "filter"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
/>
</svg>
{:else if i === "reel"}
@@ -200,9 +193,9 @@
/>
</svg>
{:else if i === "sort"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
/>
</svg>
{:else if i === "eye-closed"}
@@ -214,6 +207,12 @@
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
/>
</svg>
{:else if i === "people-nocircle"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zm66-88zM467.83 432H204.18a27.71 27.71 0 01-22-10.67 30.22 30.22 0 01-5.26-25.79c8.42-33.81 29.28-61.85 60.32-81.08C264.79 297.4 299.86 288 336 288c36.85 0 71 9 98.71 26.05 31.11 19.13 52 47.33 60.38 81.55a30.27 30.27 0 01-5.32 25.78A27.68 27.68 0 01467.83 432zM147 260c-35.19 0-66.13-32.72-69-72.93-1.42-20.6 5-39.65 18-53.62 12.86-13.83 31-21.45 51-21.45s38 7.66 50.93 21.57c13.1 14.08 19.5 33.09 18 53.52-2.87 40.2-33.8 72.91-68.93 72.91zM212.66 291.45c-17.59-8.6-40.42-12.9-65.65-12.9-29.46 0-58.07 7.68-80.57 21.62-25.51 15.83-42.67 38.88-49.6 66.71a27.39 27.39 0 004.79 23.36A25.32 25.32 0 0041.72 400h111a8 8 0 007.87-6.57c.11-.63.25-1.26.41-1.88 8.48-34.06 28.35-62.84 57.71-83.82a8 8 0 00-.63-13.39c-1.57-.92-3.37-1.89-5.42-2.89z"
/>
</svg>
{:else if i === "people"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
@@ -268,6 +267,12 @@
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
/>
</svg>
{:else if i === "refresh"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M256 48C141.31 48 48 141.32 48 256c0 114.86 93.14 208 208 208 114.69 0 208-93.31 208-208 0-114.87-93.13-208-208-208zm0 313a94 94 0 010-188h4.21l-14.11-14.1a14 14 0 0119.8-19.8l40 40a14 14 0 010 19.8l-40 40a14 14 0 01-19.8-19.8l18-18c-2.38-.1-5.1-.1-8.1-.1a66 66 0 1066 66 14 14 0 0128 0 94.11 94.11 0 01-94 94z"
/>
</svg>
{:else if i === "movary"}
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -290,6 +295,110 @@
/>
</g>
</svg>
{:else if i === "ryot"}
<svg
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 120 120"
preserveAspectRatio="xMidYMid meet"
>
<g transform="translate(0.000000,120.000000) scale(0.0500000,-0.0500000)" fill="currentColor">
<path
d="M1126 2369 c-402 -59 -745 -340 -881 -722 -167 -469 1 -995 407
-1275 518 -356 1213 -220 1555 305 199 304 233 689 93 1026 -96 232 -297 445
-527 561 -192 96 -436 136 -647 105z m221 -131 c-3 -13 -40 -241 -83 -508 -42
-267 -80 -489 -84 -493 -7 -8 -530 775 -530 793 0 12 95 78 175 121 134 72
272 107 429 108 97 1 98 1 93 -21z m302 -44 c20 -9 32 -18 26 -19 -5 -2 -35
-10 -65 -19 -30 -8 -81 -31 -112 -50 -66 -39 -65 -40 -48 60 16 90 10 85 91
63 39 -11 88 -27 108 -35z m255 -173 c128 -63 199 -178 200 -326 1 -70 -3 -89
-31 -147 -134 -284 -529 -284 -661 0 -22 48 -27 71 -27 147 0 77 4 98 26 146
68 144 195 222 351 216 62 -3 89 -10 142 -36z m-1278 -173 c33 -50 62 -97 66
-104 6 -9 -8 -10 -55 -7 -72 6 -149 -8 -209 -37 -23 -11 -43 -18 -45 -17 -1 2
19 40 45 85 47 82 116 172 131 172 5 0 35 -41 67 -92z m109 -252 c110 -52 178
-160 179 -282 1 -62 -3 -80 -34 -140 -56 -110 -137 -160 -281 -173 l-66 -6 67
-240 c36 -132 65 -242 63 -244 -9 -11 -150 142 -200 217 -95 142 -148 295
-163 467 -14 174 13 270 101 349 62 56 114 74 209 75 59 1 86 -4 125 -23z
m1516 -129 c19 -96 26 -281 10 -306 -7 -11 -89 -12 -491 -6 l-482 8 5 26 c3
14 13 76 23 138 l18 112 57 -63 c150 -164 379 -202 583 -99 97 49 205 185 230
288 7 26 9 28 18 12 5 -10 18 -60 29 -110z m-1154 -332 l63 -95 95 -362 c53
-200 98 -369 101 -377 4 -12 -16 -13 -118 -8 -141 6 -249 31 -353 81 l-70 33
-64 234 c-35 129 -65 238 -65 242 -1 4 22 16 49 25 74 26 136 65 184 117 42
46 101 155 101 188 0 32 15 17 77 -78z m619 -99 c1 0 4 -146 7 -323 l6 -321
-47 -20 c-62 -27 -200 -67 -207 -61 -4 4 -195 719 -195 731 0 3 434 -2 436 -6z
m519 -7 c9 -10 -49 -162 -90 -235 -52 -93 -145 -205 -222 -269 -37 -30 -71
-55 -75 -55 -4 0 -8 64 -8 143 0 78 -3 207 -7 285 l-6 144 201 -4 c110 -1 203
-6 207 -9z"
/>
</g>
</svg>
{:else if i === "trakt"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
<path
d="M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12-5.385 12-12 12zm0-22.789C6.05 1.211 1.211 6.05 1.211 12S6.05 22.79 12 22.79 22.79 17.95 22.79 12 17.95 1.211 12 1.211zm-7.11 17.32c1.756 1.92 4.294 3.113 7.11 3.113 1.439 0 2.801-.313 4.027-.876l-6.697-6.68-4.44 4.443zm14.288-.067c1.541-1.71 2.484-3.99 2.484-6.466 0-3.885-2.287-7.215-5.568-8.76l-6.089 6.076 9.164 9.15h.009zm-9.877-8.429L4.227 15.09l-.679-.68 5.337-5.336 6.23-6.225c-.978-.328-2.02-.509-3.115-.509C6.663 2.337 2.337 6.663 2.337 12c0 2.172.713 4.178 1.939 5.801l5.056-5.055.359.329 7.245 7.245c.15-.082.285-.164.42-.266L9.33 12.05l-4.854 4.855-.679-.679 5.535-5.535.359.331 8.46 8.437c.135-.1.255-.215.375-.316L9.39 10.027l-.083.015-.006-.007zm3.047 1.028l-.678-.676 4.788-4.79.679.689-4.789 4.785v-.008zm4.542-6.578l-5.52 5.52-.68-.679 5.521-5.52.679.684v-.005z"
/>
</svg>
{:else if i === "myanimelist"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
<path
d="M8.273 7.247v8.423l-2.103-.003v-5.216l-2.03 2.404-1.989-2.458-.02 5.285H.001L0 7.247h2.203l1.865 2.545 2.015-2.546 2.19.001zm8.628 2.069l.025 6.335h-2.365l-.008-2.871h-2.8c.07.499.21 1.266.417 1.779.155.381.298.751.583 1.128l-1.705 1.125c-.349-.636-.622-1.337-.878-2.082a9.296 9.296 0 0 1-.507-2.179c-.085-.75-.097-1.471.107-2.212a3.908 3.908 0 0 1 1.161-1.866c.313-.293.749-.5 1.1-.687.351-.187.743-.264 1.107-.359a7.405 7.405 0 0 1 1.191-.183c.398-.034 1.107-.066 2.39-.028l.545 1.749H14.51c-.593.008-.878.001-1.341.209a2.236 2.236 0 0 0-1.278 1.92l2.663.033.038-1.81h2.309zm3.992-2.099v6.627l3.107.032-.43 1.775h-4.807V7.187l2.13.03z"
/>
</svg>
{:else if i === "themoviedb"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
<path
d="M6.62 12a2.291 2.291 0 0 1 2.292-2.295h-.013A2.291 2.291 0 0 1 11.189 12a2.291 2.291 0 0 1-2.29 2.291h.013A2.291 2.291 0 0 1 6.62 12zm10.72-4.062h4.266a2.291 2.291 0 0 0 2.29-2.291 2.291 2.291 0 0 0-2.29-2.296H17.34a2.291 2.291 0 0 0-2.291 2.296 2.291 2.291 0 0 0 2.29 2.29zM2.688 20.645h8.285a2.291 2.291 0 0 0 2.291-2.292 2.291 2.291 0 0 0-2.29-2.295H2.687a2.291 2.291 0 0 0-2.291 2.295 2.291 2.291 0 0 0 2.29 2.292zm10.881-6.354h.81l1.894-4.586H15.19l-1.154 3.008h-.013l-1.135-3.008h-1.154zm4.208 0h1.011V9.705h-1.011zm2.878 0h3.235v-.93h-2.223v-.933h1.99v-.934h-1.99v-.855h2.107v-.934h-3.112zM1.31 7.941h1.01V4.247h1.31v-.895H0v.895h1.31zm3.747 0h1.011V5.959h1.958v1.984h1.011v-4.59h-1.01v1.711H6.061V3.351H5.057zm5.348 0h3.242v-.933H11.41v-.934h1.99v-.933h-1.99v-.856h2.107v-.934h-3.112zM.162 14.296h1.005v-3.52h.013l1.167 3.52h.765l1.206-3.52h.013v3.52h1.011v-4.59H3.82L2.755 12.7h-.013L1.686 9.705H.156zm14.534 6.353h1.641a3.188 3.188 0 0 0 .98-.149 2.531 2.531 0 0 0 .824-.437 2.123 2.123 0 0 0 .567-.713 2.193 2.193 0 0 0 .223-.983 2.399 2.399 0 0 0-.218-1.07 1.958 1.958 0 0 0-.586-.716 2.405 2.405 0 0 0-.873-.392 4.349 4.349 0 0 0-1.046-.13h-1.519zm1.013-3.656h.596a2.26 2.26 0 0 1 .606.08 1.514 1.514 0 0 1 .503.244 1.167 1.167 0 0 1 .34.412 1.28 1.28 0 0 1 .13.587 1.546 1.546 0 0 1-.13.658 1.127 1.127 0 0 1-.347.433 1.41 1.41 0 0 1-.518.238 2.797 2.797 0 0 1-.649.07h-.538zm4.686 3.656h1.88a2.997 2.997 0 0 0 .613-.064 1.735 1.735 0 0 0 .554-.214 1.221 1.221 0 0 0 .402-.39 1.105 1.105 0 0 0 .155-.606 1.188 1.188 0 0 0-.071-.415 1.01 1.01 0 0 0-.204-.34 1.087 1.087 0 0 0-.317-.24 1.297 1.297 0 0 0-.413-.13v-.012a1.203 1.203 0 0 0 .575-.366.962.962 0 0 0 .216-.648 1.081 1.081 0 0 0-.149-.603 1.022 1.022 0 0 0-.389-.354 1.673 1.673 0 0 0-.54-.169 4.463 4.463 0 0 0-.6-.041h-1.712zm1.011-3.734h.687a1.4 1.4 0 0 1 .24.022.748.748 0 0 1 .22.075.432.432 0 0 1 .16.147.418.418 0 0 1 .061.236.47.47 0 0 1-.055.233.433.433 0 0 1-.146.156.62.62 0 0 1-.204.084 1.058 1.058 0 0 1-.23.026h-.745zm0 1.835h.765a1.96 1.96 0 0 1 .266.02 1.015 1.015 0 0 1 .26.07.519.519 0 0 1 .204.152.406.406 0 0 1 .08.26.481.481 0 0 1-.06.253.519.519 0 0 1-.16.168.62.62 0 0 1-.217.09 1.155 1.155 0 0 1-.237.027H21.4z"
/>
</svg>
{:else if i === "gamepad"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M483.13 245.38C461.92 149.49 430 98.31 382.65 84.33A107.13 107.13 0 00352 80c-13.71 0-25.65 3.34-38.28 6.88C298.5 91.15 281.21 96 256 96s-42.51-4.84-57.76-9.11C185.6 83.34 173.67 80 160 80a115.74 115.74 0 00-31.73 4.32c-47.1 13.92-79 65.08-100.52 161C4.61 348.54 16 413.71 59.69 428.83a56.62 56.62 0 0018.64 3.22c29.93 0 53.93-24.93 70.33-45.34 18.53-23.1 40.22-34.82 107.34-34.82 59.95 0 84.76 8.13 106.19 34.82 13.47 16.78 26.2 28.52 38.9 35.91 16.89 9.82 33.77 12 50.16 6.37 25.82-8.81 40.62-32.1 44-69.24 2.57-28.48-1.39-65.89-12.12-114.37zM208 240h-32v32a16 16 0 01-32 0v-32h-32a16 16 0 010-32h32v-32a16 16 0 0132 0v32h32a16 16 0 010 32zm84 4a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-19.95A20 20 0 01336 288zm0-88a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-20 20 20 0 01-20 20z"
/>
</svg>
{:else if i === "film"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M436 80H76a44.05 44.05 0 00-44 44v264a44.05 44.05 0 0044 44h360a44.05 44.05 0 0044-44V124a44.05 44.05 0 00-44-44zM112 388a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm241.68 124H158.32a16 16 0 010-32h195.36a16 16 0 110 32zM448 388a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12z"
/>
</svg>
{:else if i === "tv"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M447.86 384H64.14A48.2 48.2 0 0116 335.86V128.14A48.2 48.2 0 0164.14 80h383.72A48.2 48.2 0 01496 128.14v207.72A48.2 48.2 0 01447.86 384z"
/>
<path
stroke="currentColor"
stroke-linecap="round"
stroke-miterlimit="10"
stroke-width="32"
d="M128 416h256"
/>
</svg>
{:else if i === "pin"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
<path
d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Z"
/>
</svg>
{:else if i === "unpin"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
<path
d="M680-840v80h-40v327L313-760l-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40Z"
/>
</svg>
{:else if i === "sparkles"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M208 512a24.84 24.84 0 01-23.34-16l-39.84-103.6a16.06 16.06 0 00-9.19-9.19L32 343.34a25 25 0 010-46.68l103.6-39.84a16.06 16.06 0 009.19-9.19L184.66 144a25 25 0 0146.68 0l39.84 103.6a16.06 16.06 0 009.19 9.19l103 39.63a25.49 25.49 0 0116.63 24.1 24.82 24.82 0 01-16 22.82l-103.6 39.84a16.06 16.06 0 00-9.19 9.19L231.34 496A24.84 24.84 0 01208 512zm66.85-254.84zM88 176a14.67 14.67 0 01-13.69-9.4l-16.86-43.84a7.28 7.28 0 00-4.21-4.21L9.4 101.69a14.67 14.67 0 010-27.38l43.84-16.86a7.31 7.31 0 004.21-4.21L74.16 9.79A15 15 0 0186.23.11a14.67 14.67 0 0115.46 9.29l16.86 43.84a7.31 7.31 0 004.21 4.21l43.84 16.86a14.67 14.67 0 010 27.38l-43.84 16.86a7.28 7.28 0 00-4.21 4.21l-16.86 43.84A14.67 14.67 0 0188 176zM400 256a16 16 0 01-14.93-10.26l-22.84-59.37a8 8 0 00-4.6-4.6l-59.37-22.84a16 16 0 010-29.86l59.37-22.84a8 8 0 004.6-4.6l22.67-58.95a16.45 16.45 0 0113.17-10.57 16 16 0 0116.86 10.15l22.84 59.37a8 8 0 004.6 4.6l59.37 22.84a16 16 0 010 29.86l-59.37 22.84a8 8 0 00-4.6 4.6l-22.84 59.37A16 16 0 01400 256z"
/>
</svg>
{:else if i === "tag"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M467 45.2A44.45 44.45 0 00435.29 32H312.36a30.63 30.63 0 00-21.52 8.89L45.09 286.59a44.82 44.82 0 000 63.32l117 117a44.83 44.83 0 0063.34 0l245.65-245.6A30.6 30.6 0 00480 199.8v-123a44.24 44.24 0 00-13-31.6zM384 160a32 32 0 1132-32 32 32 0 01-32 32z"
/>
</svg>
{/if}
<style lang="scss">
+212
View File
@@ -0,0 +1,212 @@
<script lang="ts">
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import { JobStatus, type GetJobResponse, type JobCreatedResponse } from "@/types";
import axios from "axios";
import { onDestroy, onMount } from "svelte";
import { watchedList } from "@/store";
export let modalTitle: string;
// Promise that returns the job id to watch.
export let getJobId: () => Promise<{ jobId: string } | undefined>;
export let onClose: () => void;
export let messages: {
starting: string;
};
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" = "starting";
let jobId: string | undefined;
let currentTask: string | undefined;
let latestJobStatus: GetJobResponse | undefined;
let jobFailError: string | undefined;
async function startSync() {
try {
const r = await getJobId();
console.log("startSync: Response:", r);
if (!r?.jobId) {
step = "errored";
console.error("startSync: No jobId returned!");
return;
}
jobId = r.jobId;
step = "job-running";
startJobWatcher();
} catch (err: any) {
console.error("startSync failed!", err);
step = "errored";
jobFailError = err?.response ? err?.response?.data?.error : String(err);
}
}
async function startJobWatcher() {
if (!jobId) {
console.error("startJobWatcher: No Job Id");
notify({ text: "Unable to start job watcher, no job id.", type: "error" });
return;
}
console.log("startJobWatcher: Starting..");
let seqfailedJobReqs = 0;
while (step === "job-running") {
try {
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
console.log("jobWatcher: Got job data:", r.data);
latestJobStatus = r.data;
currentTask = r.data?.currentTask;
if (r.data?.status === JobStatus.DONE) {
step = "done";
} else if (r.data?.status === JobStatus.CANCELLED) {
step = "errored";
}
// If we get here without erroring, we can reset it to 0.
seqfailedJobReqs = 0;
} catch (err) {
console.error("jobWatcher: Get job request failed!", seqfailedJobReqs, err);
seqfailedJobReqs++;
}
if (seqfailedJobReqs >= 10) {
console.error("jobWatcher: Failed 10 times in a row!");
notify({
text: "Status checker has failed 10 times in a row!",
type: "error",
time: 30000
});
step = "errored";
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (step !== "modal-closing") {
// Update our watched list
const nid = notify({ text: "Fetching updated watched list.", type: "loading" });
try {
const w = await axios.get("/watched");
if (w?.data?.length > 0) {
watchedList.update((wl) => (wl = w.data));
}
notify({ id: nid, text: "Fetched updated watched list.", type: "success" });
} catch (err) {
console.error("jobWatcher: Getting updated watched list failed!", err);
notify({ id: nid, text: "Getting updated watched list failed!", type: "error" });
}
}
}
function modalClose() {
if (step === "job-running") {
notify({
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
time: 10000
});
}
step = "modal-closing";
onClose();
}
onMount(() => {
startSync();
});
onDestroy(() => {
step = "starting";
jobId = undefined;
currentTask = undefined;
latestJobStatus = undefined;
});
</script>
<Modal title={modalTitle} maxWidth="700px" onClose={modalClose}>
<div class="ctr">
{#if step === "done"}
<Icon i="check" wh={60} />
{:else if step === "errored"}
<Icon i="close" wh={70} />
{:else}
<Spinner />
{/if}
<div>
{#if step === "starting"}
<h4 class="norm">Starting</h4>
<span>{messages.starting}</span>
{:else if step === "job-running"}
<h4 class="norm">Running</h4>
{#if currentTask}
<span>{currentTask}</span>
{/if}
{:else if step === "done"}
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
<h4 class="norm">Finished</h4>
<span>We have finished. Looks like there were no errors!</span>
{:else}
<h4 class="norm">
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus?.errors
?.length === 1
? ""
: "s"}
</h4>
<span>Job finished, but with errors:</span>
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else if step === "errored"}
<h4 class="norm">We Errored!</h4>
{#if jobFailError}
<span>{jobFailError}</span>
{:else}
<span>We errored before starting the job or the job was cancelled.</span>
{/if}
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else}
<h4 class="norm">Unknown State!</h4>
<span>We're not sure of the current job status.</span>
{/if}
</div>
</div>
</Modal>
<style lang="scss">
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: start;
align-items: start;
margin-top: 25px;
margin-bottom: 15px;
margin-left: 15px;
& > div:last-of-type {
display: flex;
flex-flow: column;
gap: 8px;
padding: 8px 0;
& > span {
font-style: italic;
&::first-letter {
text-transform: uppercase;
}
}
& > ul {
padding-left: 25px;
li::first-letter {
text-transform: uppercase;
}
}
}
}
</style>
+17
View File
@@ -5,6 +5,7 @@
export let desc: string | undefined = undefined;
export let onClose: (() => void) | undefined = undefined;
export let maxWidth = "1000px";
export let error: string | undefined = undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
</script>
<div class="backdrop"></div>
@@ -17,6 +18,9 @@
{#if desc}
<h5 class="norm">{desc}</h5>
{/if}
{#if error}
<span class="error">{error}</span>
{/if}
<slot />
</div>
</div>
@@ -75,6 +79,19 @@
@media screen and (max-width: 680px) {
margin: 20px;
}
.error {
position: sticky;
top: 0;
display: flex;
justify-content: center;
width: 100%;
padding: 10px;
background-color: rgb(221, 48, 48);
text-transform: capitalize;
color: white;
margin-bottom: 15px;
}
}
}
</style>
+1 -1
View File
@@ -138,7 +138,7 @@
font-size: 50px;
}
@media screen and (max-width: 400px) {
@media screen and (max-width: 420px) {
font-size: 45px;
}
}
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import axios from "axios";
import DropDown from "./DropDown.svelte";
import type { DropDownItem, TMDBRegions } from "@/types";
import Error from "./Error.svelte";
export let selectedCountry: string = "US";
export let disabled = false;
export let onChange: (country: string) => void;
let mappedCountries: DropDownItem[];
async function getCountries() {
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
mappedCountries = c.results
.map((cc) => {
return {
id: cc.iso_3166_1,
value: cc.english_name
} as DropDownItem;
})
.sort((a, b) => a.value.localeCompare(b.value));
}
</script>
{#await getCountries() then}
<DropDown
placeholder="Select a country"
bind:active={selectedCountry}
options={mappedCountries}
onChange={() => onChange(selectedCountry)}
isDropDownItem={true}
{disabled}
/>
{:catch err}
<Error error={err} pretty="Failed to load countries!" />
{/await}
+34 -3
View File
@@ -18,17 +18,44 @@
export let tvId: number;
export let seasons: TMDBShowSeason[];
export let watchedItem: Watched; // Watched list item id
export let watchedItem: Watched | undefined;
let activeSeason = 1;
let activeSeason =
typeof watchedItem?.lastViewedSeason === "number" ? watchedItem?.lastViewedSeason : 1;
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
async function sdr(seasonNum: number) {
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).data as TMDBSeasonDetails;
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
params: {
watchedId: watchedItem?.id
}
});
try {
if (watchedItem?.id) {
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
if (hVal) {
watchedItem.lastViewedSeason = seasonNum;
watchedList.update((w) => w);
} else {
console.error(
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved."
);
notify({ type: "error", text: "Failed when saving last viewed season" });
}
}
} catch (err) {
console.error("SeasonList: sdr: Failed to process lastviewedseason-saved header.", err);
}
return resp.data as TMDBSeasonDetails;
}
// Add/update watched season
function updateWatchedSeason(seasonNumber: number, status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error("updateWatchedSeason: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedSeasonAddResponse>(`/watched/season`, {
@@ -66,6 +93,10 @@
}
function handleStatusClick(type: WatchedStatus | "DELETE", seasonNumber: number) {
if (!watchedItem) {
console.error("handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedSeasons?.find((s) => s.seasonNumber === seasonNumber);
if (!ws) {
+51 -2
View File
@@ -17,7 +17,7 @@
$: settings = $userSettings;
export let ep: TMDBSeasonDetailsEpisode;
export let watchedItem: Watched;
export let watchedItem: Watched | undefined;
let isHidden = false;
@@ -26,6 +26,10 @@
}
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
if (!watchedItem) {
console.error("SeasonListEpisode: updateWatchedEpisode: No watched item.");
return;
}
const nid = notify({ text: `Saving`, type: "loading" });
axios
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
@@ -53,6 +57,47 @@
} else {
wEntry.activity = [r.data.addedActivity];
}
try {
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
if (epHookResp && Object.keys(epHookResp).length > 0) {
if (epHookResp.errors && epHookResp.errors.length > 0) {
console.error(
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
epHookResp.errors
);
notify({
type: "error",
text: "Some automations have failed, check console for more info."
});
}
if (epHookResp.addedActivities && epHookResp.addedActivities.length > 0) {
wEntry.activity.push(...epHookResp.addedActivities);
}
if (epHookResp.watchedSeason) {
if (!wEntry.watchedSeasons) {
wEntry.watchedSeasons = [epHookResp.watchedSeason];
} else {
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
(s) => s.id === epHookResp.watchedSeason?.id
);
if (watchedSeasonIdx === -1) {
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
} else {
wEntry.watchedSeasons[watchedSeasonIdx] = epHookResp.watchedSeason;
}
}
}
if (epHookResp.newShowStatus) {
wEntry.status = epHookResp.newShowStatus;
}
}
} catch (err) {
console.error("Failed to process episodeStatusChangedHookResponse", err);
notify({
type: "error",
text: "Failed to process automation response, check console for more info."
});
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
}
@@ -64,6 +109,10 @@
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (!watchedItem) {
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
return;
}
if (type === "DELETE") {
const ws = watchedItem.watchedEpisodes?.find(
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
@@ -129,7 +178,7 @@
<span class="episode-name">{ep.name}</span>
{#if ep.runtime}
<span class="episode-runtime" title="This episode has a runtime of {ep.runtime} minutes."
>{ep.runtime}m</span
>{ep.runtime} min</span
>
{/if}
</span>
+1 -1
View File
@@ -7,7 +7,7 @@
<style lang="scss">
.loader {
width: 16px;
height: 13px;
height: 12px;
border: 2px solid #000;
border-bottom-color: transparent;
border-radius: 50%;
+101 -132
View File
@@ -4,10 +4,11 @@
import Poster from "@/lib/poster/Poster.svelte";
import PosterList from "@/lib/poster/PosterList.svelte";
import { activeFilters, activeSort, serverFeatures, userSettings } from "@/store";
import type { Watched, WatchedEpisode, WatchedSeason } from "@/types";
import type { Watched } from "@/types";
import GamePoster from "./poster/GamePoster.svelte";
import { get } from "svelte/store";
import { seasonAndEpToReadable } from "./util/helpers";
import { getLatestWatchedInTv } from "./util/helpers";
import { notify } from "./util/notify";
export let list: Watched[];
export let isPublicList: boolean = false;
@@ -57,140 +58,104 @@
$: (watched, filters, sort), filt();
function filt() {
// Set watched to list and sort it.
watched = list.sort((a, b) => {
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
} else if (sort[0] === "ALPHA") {
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
if (sort[1] === "UP") {
return atitle.localeCompare(btitle);
} else if (sort[1] === "DOWN") {
return btitle.localeCompare(atitle);
try {
// Set watched to list and sort it.
watched = list
.sort((a, b) => {
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
} else if (sort[0] === "ALPHA") {
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
if (sort[1] === "UP") {
return atitle.localeCompare(btitle);
} else if (sort[1] === "DOWN") {
return btitle.localeCompare(atitle);
}
} else if (sort[0] === "LASTCHANGED") {
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
} else if (sort[0] === "LASTFIN") {
const aLastFinishActivity = a.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt)
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
);
const bLastFinishActivity = b.activity
?.sort(
(aa, bb) =>
Date.parse(bb.customDate ?? bb.updatedAt) -
Date.parse(aa.customDate ?? aa.updatedAt)
)
?.find(
(aa) =>
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
);
if (!aLastFinishActivity) return 1;
if (!bLastFinishActivity) return -1;
const alfaDate = aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
const blfaDate = bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
if (sort[1] === "UP") return Date.parse(alfaDate) - Date.parse(blfaDate);
else if (sort[1] === "DOWN") return Date.parse(blfaDate) - Date.parse(alfaDate);
} else if (sort[0] === "RATING") {
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
})
.sort((a, b) => {
if (a.pinned && !b.pinned) return -1;
if (!a.pinned && b.pinned) return 1;
return 0;
});
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
if (!features.games) {
const af = get(activeFilters);
af.type = af.type?.filter((a) => a !== "game");
filters.type = filters.type.filter((f) => f !== "game");
}
// Now apply filters to watch list.
if (filters.status.length > 0 && filters.type.length > 0) {
// If status and type filters applied, combine both.
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) =>
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
}
} else if (sort[0] === "LASTCHANGED") {
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
} else if (sort[0] === "RATING") {
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
});
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
if (!features.games) {
const af = get(activeFilters);
af.type = af.type?.filter((a) => a !== "game");
filters.type = filters.type.filter((f) => f !== "game");
}
// Now apply filters to watch list.
if (filters.status.length > 0 && filters.type.length > 0) {
// If status and type filters applied, combine both.
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) =>
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
} else if (filters.type.length > 0) {
// Only filter type
watched = watched.filter((w) =>
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else {
watched = watched.filter(
(w) =>
filters.status.includes(w.status?.toLowerCase()) &&
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
}
} else if (filters.type.length > 0) {
// Only filter type
watched = watched.filter((w) =>
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
);
} else if (filters.status.length > 0) {
// Only filter status
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
);
} else {
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
}
}
}
// Get biggest season watching or biggest season watched.
// This could probably be simpler but -_-
function getLatestWatchedInTv(
ws: WatchedSeason[] | undefined,
we: WatchedEpisode[] | undefined
): string {
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
return "";
}
let biggestSeasonWatched = -1;
let biggestSeasonWatching = -1;
if (ws && ws.length > 0) {
for (let i = 0; i < ws.length; i++) {
const s = ws[i];
if (s.status === "WATCHING") {
if (s.seasonNumber > biggestSeasonWatching) {
biggestSeasonWatching = s.seasonNumber;
}
} else if (s.status === "FINISHED") {
if (s.seasonNumber > biggestSeasonWatched) {
biggestSeasonWatched = s.seasonNumber;
}
} else if (filters.status.length > 0) {
// Only filter status
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
watched = watched.filter(
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
);
} else {
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
}
}
}
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
// Look for biggest watched/watching episode in season if any.
// Does same thing as above.
let episode: WatchedEpisode | undefined;
if (we && we.length > 0) {
let biggestEpisodeWatched: WatchedEpisode | undefined;
let biggestEpisodeWatching: WatchedEpisode | undefined;
for (let i = 0; i < we.length; i++) {
const s = we[i];
if (season >= 0 && s.seasonNumber !== season) continue;
if (s.status === "WATCHING") {
if (!biggestEpisodeWatching) {
biggestEpisodeWatching = s;
}
if (
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
) {
biggestEpisodeWatching = s;
}
} else if (s.status === "FINISHED") {
if (!biggestEpisodeWatched) {
biggestEpisodeWatched = s;
}
if (
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
) {
biggestEpisodeWatched = s;
}
}
}
if (biggestEpisodeWatched || biggestEpisodeWatching) {
episode =
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
}
}
if (season >= 0 && episode) {
return seasonAndEpToReadable(season, episode.episodeNumber);
} else if (season >= 0) {
return `Season ${season}`;
} else if (episode) {
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
} else {
return "";
} catch (err) {
console.error("filt: Failed to filter/sort current list!", err);
notify({ text: "Failed to filter/sort list!", type: "error", time: 6000 });
}
}
</script>
@@ -216,6 +181,8 @@
dateAdded: w.createdAt,
dateModified: w.updatedAt
}}
fluidSize={true}
pinned={w.pinned}
/>
{:else if w.content}
<Poster
@@ -237,6 +204,8 @@
dateModified: w.updatedAt,
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
}}
fluidSize={true}
pinned={w.pinned}
/>
{/if}
{/each}
+3 -2
View File
@@ -13,9 +13,10 @@ export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
const tooltip = document.getElementById("tooltip");
const show = () => {
if (!condition) return;
const trimmedText = text?.trim();
if (!condition || !trimmedText) return;
if (tooltip) {
tooltip.innerHTML = text;
tooltip.innerHTML = trimmedText;
const nrect = node.getBoundingClientRect();
const trect = tooltip.getBoundingClientRect();
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
+123
View File
@@ -0,0 +1,123 @@
<script lang="ts">
import { onMount } from "svelte";
import Modal from "../Modal.svelte";
import Icon from "../Icon.svelte";
import { notify } from "../util/notify";
export let contentTitle: string;
export let thoughts: string;
export let onChange: (newThoughts: string) => Promise<boolean>;
let modalOpen = false;
let textarea: HTMLTextAreaElement | undefined;
$: thoughtsToDisplay = thoughts ? thoughts : `Set thoughts on ${contentTitle}`;
function resizeTextarea() {
if (!textarea) {
return;
}
textarea.style.height = "";
textarea.style.height = textarea.scrollHeight + "px";
}
$: textarea, resizeTextarea();
</script>
<button
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
on:click={() => {
modalOpen = !modalOpen;
if (modalOpen) {
resizeTextarea();
}
}}
>
<i><Icon i="pencil" wh={24} /></i>
<p>{thoughtsToDisplay}</p>
</button>
{#if modalOpen}
<Modal
title="Your Thoughts"
desc="View or modify your thoughts on {contentTitle}"
onClose={async () => {
if (!textarea) {
notify({
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!"
});
return;
}
// If thoughts weren't changed or changes saved successfully.
if (thoughts === textarea.value || (await onChange(textarea.value))) {
modalOpen = false;
}
}}
>
<textarea
name="Thoughts"
rows="3"
placeholder={`My thoughts on ${contentTitle}`}
value={thoughts}
bind:this={textarea}
on:input={resizeTextarea}
/>
</Modal>
{/if}
<style lang="scss">
button.thoughts {
position: relative;
width: 100%;
text-align: start;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
max-height: 100px;
opacity: 0.5;
transition: opacity 150ms ease-in-out;
&:hover {
opacity: 1;
}
i {
display: flex;
position: absolute;
bottom: 4px;
right: 5px;
opacity: 0;
transform: scale(0.5);
transition:
opacity 150ms ease-in-out,
transform 150ms ease-in-out;
}
&:hover i {
transform: scale(1);
opacity: 1;
}
&.placeholdered {
padding: 12px 12px;
}
p {
max-height: 90px;
overflow: hidden;
}
&.long p {
mask: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0,
rgba(0, 0, 0, 1) 40%,
rgba(0, 0, 0, 0) 95%,
rgba(0, 0, 0, 0) 0
);
}
}
textarea {
border: 0;
}
</style>
+18
View File
@@ -98,6 +98,24 @@
/>
</svg>
</a>
{:else if i === "BBC iPlayer"}
<a href="https://www.bbc.co.uk/iplayer" target="_blank">
<svg width={80} height={wh} viewBox="0 0 605 107" xmlns="http://www.w3.org/2000/svg">
<path
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
></path>
</svg>
</a>
{:else if i === "Hulu"}
<a href="https://hulu.com" target="_blank">
<svg width={wh} height={wh} viewBox="0 0 64 23" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
></path>
</svg>
</a>
{:else}
<span>{i}</span>
{/if}
+12 -5
View File
@@ -5,18 +5,25 @@
export let providers: TMDBContentWatchProviders;
</script>
{#if providers?.flatrate?.length > 0}
{#if providers?.flatrate?.length > 0 || providers?.free?.length > 0}
<div class="streaming-providers">
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{#if providers?.flatrate?.length > 0}
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
{#if providers?.free?.length > 0}
{#each providers.free as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
{/if}
</div>
{/if}
<style lang="scss">
.streaming-providers {
display: flex;
align-items: flex-end;
align-items: center;
flex-wrap: wrap;
gap: 15px;
margin-top: auto;
+7 -2
View File
@@ -2,6 +2,7 @@
import { wlDetailedView } from "@/store";
import type { WLDetailedViewOption } from "@/types";
import { get } from "svelte/store";
import { page } from "$app/stores";
function detailClicked(d: WLDetailedViewOption) {
let dv = get(wlDetailedView);
@@ -16,7 +17,7 @@
$: dve = $wlDetailedView;
</script>
<div class="menu">
<div class={`menu${$page.url?.pathname.startsWith("/search/") ? " on-search-page" : ""}`}>
<div class="inner">
<h4 class="norm sm-caps">Shown Details</h4>
<button
@@ -49,13 +50,17 @@
<style lang="scss">
div.menu {
width: 200px;
right: 58px;
right: 92px;
&:before {
left: 3px;
}
}
div.menu.on-search-page:before {
left: 86px;
}
div.inner {
h4 {
margin-bottom: 8px;
+2 -2
View File
@@ -85,10 +85,10 @@
<style lang="scss">
div.menu {
width: 200px;
right: 35px;
right: 47px;
&:before {
left: 23px;
left: 38px;
}
}
+7
View File
@@ -45,6 +45,12 @@
>
Last Changed
</button>
<button
class={`plain ${sort[0] == "LASTFIN" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("LASTFIN")}
>
Last Finished
</button>
<button
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("RATING")}
@@ -63,6 +69,7 @@
<style lang="scss">
div {
width: 180px;
right: 90px;
&:before {
left: 21px;
+19 -3
View File
@@ -34,6 +34,8 @@
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: ExtraDetailsGame | undefined = undefined;
export let fluidSize = false;
export let pinned = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
@@ -107,8 +109,13 @@
}
onMount(() => {
if (small && containerEl) {
containerEl.classList.add("small");
if (containerEl) {
if (small) {
containerEl.classList.add("small");
}
if (fluidSize) {
containerEl.classList.add("fluid-size");
}
}
});
</script>
@@ -128,7 +135,7 @@
on:mouseleave={() => (posterActive = false)}
on:click={() => (posterActive = true)}
on:keypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}`}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
>
<div
class={`container${!poster || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
@@ -208,6 +215,10 @@
cursor: pointer;
}
li.pinned:not(.active) .container {
outline: 3px solid gold;
}
.container {
display: flex;
flex-flow: column;
@@ -222,6 +233,11 @@
// aspect-ratio: 2/3;
transition: transform 150ms ease;
&.fluid-size {
height: 100%;
width: 100%;
}
img {
width: 100%;
height: 100%;
+22 -7
View File
@@ -36,6 +36,8 @@
export let disableInteraction = false;
export let hideButtons = false;
export let extraDetails: ExtraDetails | undefined = undefined;
export let fluidSize = false;
export let pinned = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
@@ -99,8 +101,13 @@
}
onMount(() => {
if (small && containerEl) {
containerEl.classList.add("small");
if (containerEl) {
if (small) {
containerEl.classList.add("small");
}
if (fluidSize) {
containerEl.classList.add("fluid-size");
}
}
});
</script>
@@ -120,7 +127,7 @@
on:mouseleave={() => (posterActive = false)}
on:click={() => (posterActive = true)}
on:keypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}`}
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
>
<div
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
@@ -140,7 +147,7 @@
}}
/>
{/if}
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
{#if ($page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")) && extraDetails && dve && dve.length > 0}
<div class="extra-details">
<!--
This is one line because svelte leaves whitespace,
@@ -196,6 +203,10 @@
cursor: pointer;
}
li.pinned:not(.active) .container {
outline: 3px solid gold;
}
.container {
display: flex;
flex-flow: column;
@@ -203,13 +214,17 @@
overflow: hidden;
flex: 1 1;
border-radius: 5px;
min-width: 170px;
width: 170px;
height: 100%;
min-height: 256.367px;
position: relative;
// aspect-ratio: 2/3;
aspect-ratio: 170000/256367;
transition: transform 150ms ease;
&.fluid-size {
height: 100%;
width: 100%;
}
img {
width: 100%;
height: 100%;
+21
View File
@@ -41,5 +41,26 @@
padding: 15px 8px;
margin: 5px 0;
}
&:global(.wrapped li) {
@media screen and (max-width: 390px) {
flex: 0 48.5%;
}
@media screen and (max-width: 352px) {
flex: 0 48%;
}
@media screen and (width <= 349px) {
flex: unset;
}
}
&:global(.wrapped .container) {
min-width: 150px !important;
@media screen and (width <= 349px), screen and (width > 390px) {
width: 170px !important;
min-height: 256.367px !important;
}
}
}
</style>
+219
View File
@@ -0,0 +1,219 @@
<script lang="ts">
import type {
ArrDetailsResponse,
ArrInfoResponse,
ArrRequestResponse,
ArrRequestStatus,
ContentType
} from "@/types";
import axios from "axios";
import { onMount } from "svelte";
import Icon from "../Icon.svelte";
import tooltip from "../actions/tooltip";
import { msToAmountsOfTime } from "../util/helpers";
import { notify } from "../util/notify";
export let type: ContentType;
export let tmdbId: number;
export let openRequestModal: () => void;
let existingRequest: ArrRequestResponse | undefined;
let info: ArrInfoResponse | undefined;
// The status, extra added string types are set in this file.
let status: ArrDetailsResponse | "available" | "requested" | ArrRequestStatus | undefined;
let estimatedCompletionIn: string | undefined;
async function getInfo() {
try {
if (!existingRequest) {
console.warn("getInfo called before existingRequest exists.");
return;
}
if (
existingRequest.status !== "APPROVED" &&
existingRequest.status !== "AUTO_APPROVED" &&
existingRequest.status !== "FOUND"
) {
console.debug("getInfo: Request is not in an approved state.. not continuing.");
return;
}
const resp = await axios.get<ArrInfoResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/info/${existingRequest.id}`
);
if (resp?.data) {
info = resp.data;
if (info.hasFile) {
status = "available";
} else {
getStatus();
}
}
} catch (err: any) {
if (err?.response?.status === 404 && err?.response?.data?.error === "request deleted") {
return;
}
console.error("ArrRequestButton: getInfo failed!", err);
notify({
text: `Failed when getting info from ${type === "movie" ? "Radarr" : "Sonarr"}`,
type: "error"
});
}
}
async function getStatus(ev: MouseEvent | undefined = undefined) {
try {
if (!existingRequest) {
console.warn("getStatus called before existingRequest exists.");
return;
}
const statusResp = await axios.get<ArrDetailsResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/status/${existingRequest.serverName}/${existingRequest.arrId}`
);
if (statusResp.status === 204) {
status = "requested";
} else if (statusResp?.data) {
status = statusResp.data;
const estMs =
new Date(status.estimatedCompletionTime).getTime() - new Date(Date.now()).getTime();
const est = msToAmountsOfTime(estMs);
if (est.days > 0) {
estimatedCompletionIn = `${est.days} Day${est.days > 1 ? "s" : ""}`;
} else if (est.hours > 0) {
estimatedCompletionIn = `${est.hours} Hour${est.hours > 1 ? "s" : ""}`;
} else if (est.minutes > 0) {
estimatedCompletionIn = `${est.minutes} Minute${est.minutes > 1 ? "s" : ""}`;
} else if (est.seconds > 0) {
estimatedCompletionIn = `${est.seconds} Second${est.seconds > 1 ? "s" : ""}`;
} else {
estimatedCompletionIn = undefined;
}
}
if (ev?.target) {
setTimeout(() => {
(ev?.target as HTMLButtonElement)?.blur();
}, 250);
}
} catch (err) {
console.error("ArrRequestButton: getStatus failed!", err);
notify({
text: `Failed to fetch content status from ${type === "movie" ? "Radarr" : "Sonarr"}`,
type: "error"
});
}
}
async function lookForExisting() {
try {
const existingRequestResp = await axios.get<ArrRequestResponse>(
`/arr/${type === "movie" ? "rad" : "son"}/request/${tmdbId}`
);
if (existingRequestResp?.data && existingRequestResp?.data?.arrId) {
existingRequest = existingRequestResp?.data;
getInfo();
} else if (existingRequestResp?.data) {
// If no arrId, use status in request (pending, denied, etc)
console.log("No arrId in request resp.. using request status for btn status if set.");
if (existingRequestResp?.data?.status) {
status = existingRequestResp?.data?.status;
}
}
} catch (err) {
console.error("ArrRequestButton: lookForExisting failed!", err);
notify({
text: `Failed when looking for an existing request for this content`,
type: "error"
});
}
}
// Used by Request modals to set existing request data in this component from their request response.
export function setExistingRequest(r: ArrRequestResponse) {
if (existingRequest) {
console.error(
"ArrRequestButton: setExistingRequest: Existing request has already been defined! Not continuing."
);
return;
}
if (!r) {
console.error("ArrRequestButton: setExistingRequest: No response passed in.", r);
return;
}
console.debug("ArrRequestButton: setExistingRequest: Running..", r);
existingRequest = r;
if (existingRequest?.status) {
status = existingRequest?.status;
}
getInfo();
}
onMount(() => {
lookForExisting();
});
</script>
{#if typeof status === "object" || status === "requested"}
<button
on:click={getStatus}
use:tooltip={{
text:
status === "requested"
? "Waiting for approval or download to start"
: status.status === "downloading"
? estimatedCompletionIn
? `Done in ${estimatedCompletionIn}`
: "Estimation Unavailable"
: status.status === "paused"
? "Download has been paused"
: "",
pos: "bot"
}}
>
<div><Icon i="refresh" /></div>
<span>
{#if status === "requested"}
Requested
{:else}
{status.status}
{status.status === "downloading" ? (status?.progress ? `(${status?.progress}%)` : "") : ""}
{/if}
</span>
</button>
{:else if status === "available" || status === "FOUND"}
<button disabled>Available</button>
{:else if status === "PENDING"}
<button disabled>Pending</button>
{:else if status === "APPROVED"}
<button disabled>Approved</button>
{:else if status === "AUTO_APPROVED"}
<button disabled>Auto Approved</button>
{:else if status === "DENIED"}
<button disabled>Denied</button>
{:else}
<button on:click={openRequestModal}>Request</button>
{/if}
<style lang="scss">
button {
max-width: fit-content;
text-transform: capitalize;
position: relative;
& > div {
display: none;
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
&:hover:not(:focus) {
& > div {
display: block;
}
& > span {
visibility: hidden;
}
}
}
</style>
+70 -15
View File
@@ -1,14 +1,22 @@
<script lang="ts">
import axios from "axios";
import Modal from "../Modal.svelte";
import type { RadarrSettings, RadarrTestResponse, TMDBMovieDetails } from "@/types";
import type {
ArrRequestResponse,
RadarrSettings,
RadarrTestResponse,
TMDBMovieDetails
} from "@/types";
import { notify } from "../util/notify";
import DropDown from "../DropDown.svelte";
import Setting from "../settings/Setting.svelte";
import Spinner from "../Spinner.svelte";
export let content: TMDBMovieDetails;
export let onClose: () => void;
export let onClose: (r: ArrRequestResponse | undefined) => void;
export let approveMode = false;
export let originalRequest: ArrRequestResponse | undefined = undefined;
let servarrs: RadarrSettings[];
let selectedServarrIndex: number;
@@ -27,7 +35,9 @@
notify({ text: "No servers found", type: "error" });
}
inputsDisabled = false;
processOriginalRequest();
} catch (err) {
console.error("Failed to get servers!", err);
notify({ text: "Failed to load servers", type: "error" });
}
}
@@ -38,7 +48,10 @@
const r = await axios.get<RadarrTestResponse>(`/arr/rad/config/${name}`);
selectedServerCfg = r.data;
inputsDisabled = false;
} catch (err) {}
} catch (err) {
console.error("Failed to get config!", err);
notify({ text: "Failed to load config", type: "error" });
}
}
async function request() {
@@ -66,17 +79,22 @@
notify({ id: nid, text: "No Root Folder Found", type: "error" });
return;
}
await axios.post("/arr/rad/request", {
serverName: server.name,
title: content.title,
year: new Date(content.release_date)?.getFullYear(),
tmdbId: content.id,
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path
});
notify({ id: nid, text: "Request complete", type: "success" });
const resp = await axios.post<ArrRequestResponse>(
`/arr/rad/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
{
serverName: server.name,
title: content.title,
year: new Date(content.release_date)?.getFullYear(),
tmdbId: content.id,
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path
}
);
addRequestRunning = false;
onClose();
if (resp.data) {
notify({ id: nid, text: "Request complete", type: "success" });
onClose(resp.data);
}
} catch (err) {
console.error("content request failed!", err);
addRequestRunning = false;
@@ -84,6 +102,37 @@
}
}
function processOriginalRequest() {
if (!originalRequest) {
return;
}
try {
if (originalRequest.requestJson) {
const ogr = JSON.parse(originalRequest.requestJson);
if (!ogr) {
console.info("processOriginalRequest: No json.", ogr);
return;
}
if (ogr?.serverName) {
console.debug("processOriginalRequest: restoring server name:", ogr?.serverName);
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
if (idx !== -1) {
selectedServarrIndex = idx;
}
}
} else {
notify({
type: "error",
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
time: 10000
});
}
} catch (err) {
console.error("processOriginalRequest: Failed!", err);
notify({ text: "Failed when processing original request!", type: "error" });
}
}
$: {
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
const s = servarrs[selectedServarrIndex];
@@ -98,7 +147,11 @@
getServers();
</script>
<Modal title="Request" desc={content.title} {onClose}>
<Modal
title={approveMode ? "Approve Request" : "Request"}
desc={content.title}
onClose={() => onClose(undefined)}
>
<div class="req-ctr">
{#if servarrs}
{@const server = servarrs[selectedServarrIndex]}
@@ -117,7 +170,9 @@
</Setting>
{/if}
<button on:click={request} disabled={addRequestRunning}>Request</button>
<button on:click={request} disabled={addRequestRunning}>
{approveMode ? "Approve" : "Request"}
</button>
{:else}
<Spinner />
{/if}
+96 -25
View File
@@ -1,7 +1,13 @@
<script lang="ts">
import axios from "axios";
import Modal from "../Modal.svelte";
import type { ListBoxItem, SonarrSettings, SonarrTestResponse, TMDBShowDetails } from "@/types";
import type {
ArrRequestResponse,
ListBoxItem,
SonarrSettings,
SonarrTestResponse,
TMDBShowDetails
} from "@/types";
import { notify } from "../util/notify";
import DropDown from "../DropDown.svelte";
import Setting from "../settings/Setting.svelte";
@@ -11,7 +17,10 @@
const animeKeywordId = 210024;
export let content: TMDBShowDetails;
export let onClose: () => void;
export let onClose: (r: ArrRequestResponse | undefined) => void;
export let approveMode = false;
export let originalRequest: ArrRequestResponse | undefined = undefined;
let servarrs: SonarrSettings[];
let selectedServarrIndex: number;
@@ -37,7 +46,9 @@
notify({ text: "No servers found", type: "error" });
}
inputsDisabled = false;
processOriginalRequest();
} catch (err) {
console.error("Failed to get servers!", err);
notify({ text: "Failed to load servers", type: "error" });
}
}
@@ -48,7 +59,10 @@
const r = await axios.get<SonarrTestResponse>(`/arr/son/config/${name}`);
selectedServerCfg = r.data;
inputsDisabled = false;
} catch (err) {}
} catch (err) {
console.error("Failed to get config!", err);
notify({ text: "Failed to load config", type: "error" });
}
}
async function request() {
@@ -76,27 +90,33 @@
notify({ id: nid, text: "No Root Folder Found", type: "error" });
return;
}
await axios.post("/arr/son/request", {
serverName: server.name,
title: content.name,
year: new Date(content.first_air_date)?.getFullYear(),
tvdbId: content.external_ids.tvdb_id,
seriesType: content.keywords.results?.find((k) => k.id == animeKeywordId)
? "anime"
: "standard",
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path,
languageProfile: server.languageProfile,
seasons: seasonItems.map((s) => {
return {
seasonNumber: s.id,
monitored: s.value
};
})
});
notify({ id: nid, text: "Request complete", type: "success" });
const resp = await axios.post(
`/arr/son/request${approveMode && originalRequest ? `/approve/${originalRequest.id}` : ""}`,
{
serverName: server.name,
title: content.name,
year: new Date(content.first_air_date)?.getFullYear(),
tmdbId: content.id,
tvdbId: content.external_ids.tvdb_id,
seriesType: content.keywords.results?.find((k) => k.id == animeKeywordId)
? "anime"
: "standard",
qualityProfile: server.qualityProfile,
rootFolder: rootFolder.path,
languageProfile: server.languageProfile,
seasons: seasonItems.map((s) => {
return {
seasonNumber: s.id,
monitored: s.value
};
})
}
);
addRequestRunning = false;
onClose();
if (resp?.data) {
notify({ id: nid, text: "Request complete", type: "success" });
onClose(resp.data);
}
} catch (err) {
console.error("content request failed!", err);
addRequestRunning = false;
@@ -104,6 +124,51 @@
}
}
function processOriginalRequest() {
if (!originalRequest) {
return;
}
try {
if (originalRequest.requestJson) {
const ogr = JSON.parse(originalRequest.requestJson);
if (!ogr) {
console.info("processOriginalRequest: No json.", ogr);
return;
}
if (ogr?.seasons?.length > 0) {
console.debug("processOriginalRequest: Found seasons.. restoring.");
for (let i = 0; i < ogr.seasons.length; i++) {
const s = ogr.seasons[i];
// Default is not monitored, so no point going through the whole rigmarole to 'restore' the default value.
if (!s.monitored) {
continue;
}
const sItem = seasonItems?.find((si) => si.id === s.seasonNumber);
if (sItem) {
sItem.value = s.monitored;
}
}
}
if (ogr?.serverName) {
console.debug("processOriginalRequest: restoring server name:", ogr?.serverName);
const idx = servarrs?.findIndex((s) => s.name === ogr?.serverName);
if (idx !== -1) {
selectedServarrIndex = idx;
}
}
} else {
notify({
type: "error",
text: "Full original request could not be restored. You may continue, but prefilled settings may not be true to the original request.",
time: 10000
});
}
} catch (err) {
console.error("processOriginalRequest: Failed!", err);
notify({ text: "Failed when processing original request!", type: "error" });
}
}
$: {
if (typeof selectedServarrIndex !== "undefined" && servarrs?.length > 0) {
const s = servarrs[selectedServarrIndex];
@@ -118,7 +183,11 @@
getServers();
</script>
<Modal title="Request" desc={content.name} {onClose}>
<Modal
title={approveMode ? "Approve Request" : "Request"}
desc={content.name}
onClose={() => onClose(undefined)}
>
<div class="req-ctr">
{#if servarrs}
{@const server = servarrs[selectedServarrIndex]}
@@ -141,7 +210,9 @@
</Setting>
{/if}
<button on:click={request} disabled={addRequestRunning}>Request</button>
<button on:click={request} disabled={addRequestRunning}>
{approveMode ? "Approve" : "Request"}
</button>
{:else}
<Spinner />
{/if}
+25 -3
View File
@@ -1,17 +1,28 @@
<script lang="ts">
export let title: string;
export let title: string = "";
export let desc: string = "";
export let row: boolean = false;
export let tag: string | undefined = undefined;
</script>
<div class={[row ? "row" : "", "setting-ctr"].join(" ")}>
{#if row}
<div>
<h4 class="norm">{title}</h4>
<h4 class="norm">
{title}
{#if tag}
<span class="tag">{tag}</span>
{/if}
</h4>
<h5 class="norm">{desc}</h5>
</div>
{:else}
<h4 class="norm">{title}</h4>
<h4 class="norm">
{title}
{#if tag}
<span class="tag">{tag}</span>
{/if}
</h4>
<h5 class="norm">{desc}</h5>
{/if}
@@ -21,6 +32,17 @@
<style lang="scss">
h4 {
font-size: 16px;
display: flex;
flex-flow: row;
align-items: center;
gap: 5px;
& > .tag {
font-size: 10px;
background-color: $accent-color;
padding: 3px 5px;
border-radius: 5px;
}
}
h5 {
+5
View File
@@ -45,5 +45,10 @@
&:hover {
border-style: solid;
}
:global(svg) {
width: 24px;
min-width: 24px;
}
}
</style>
+1 -1
View File
@@ -6,7 +6,7 @@
.settings {
display: flex;
flex-flow: column;
gap: 20px;
gap: 15px;
width: 100%;
}
</style>
+1 -4
View File
@@ -30,16 +30,13 @@
> span:first-of-type {
font-weight: bold;
font-size: 20px;
margin-top: auto;
&.large {
font-size: 32px;
}
}
> span:last-child {
margin-top: auto;
}
.disclaimer {
position: absolute;
top: 5px;
+58
View File
@@ -0,0 +1,58 @@
<script lang="ts">
import type { Watched } from "@/types";
import tooltip from "../actions/tooltip";
import Icon from "../Icon.svelte";
import TagMenu from "./TagMenu.svelte";
import { tagWatched, untagWatched } from "./api";
import { onMount } from "svelte";
export let watchedItem: Watched;
let menuOpen = false;
onMount(() => {
const onScroll = () => {
menuOpen = false;
};
window.addEventListener("scroll", onScroll);
return () => {
window.removeEventListener("scroll", onScroll);
};
});
</script>
<div>
<button
on:click={() => (menuOpen = !menuOpen)}
use:tooltip={{
text: `Add to a Tag`,
pos: "bot"
}}
>
<Icon i={"tag"} wh={19} />
</button>
{#if menuOpen}
<TagMenu
titleText="Add To Tag"
classes="from-add-to-tag-btn"
selectedTags={watchedItem.tags}
onTagClick={(tag, remove) => {
console.debug("Tag: Adding content to tag. Remove?:", remove);
if (remove) {
untagWatched(watchedItem.id, tag);
} else {
tagWatched(watchedItem.id, tag);
}
}}
/>
{/if}
</div>
<style lang="scss">
div {
position: relative;
}
</style>
+154
View File
@@ -0,0 +1,154 @@
<script lang="ts">
import { tags } from "@/store";
import Modal from "../Modal.svelte";
import Setting from "../settings/Setting.svelte";
import SettingsList from "../settings/SettingsList.svelte";
import ColorSelector from "../ColorSelector.svelte";
import { notify } from "../util/notify";
import axios from "axios";
import type { Tag, TagAddRequest } from "@/types";
import { get } from "svelte/store";
import { onMount } from "svelte";
export let onClose: () => void;
// Passing an existing tag will enable 'Edit Tag' mode.
export let existingTag: Tag | undefined = undefined;
const colorPresets = [
["#36BA98", "#000"],
["#FF4191", "#231731"],
["#173B45", "#FFE0C9"],
["#6EACDA", "#021526"],
["#180161", "#FF9E73"],
["#FFBC0A", "#2F0A39"],
["#C1FF9B", "#321673"],
["#F5FFC6", "#5126B5"],
["#6D213C", "#FAFF70"],
["#F7C4A5", "#2f0082"],
["#F7EFE5", "#63338c"]
];
function getRandomColorPreset() {
return colorPresets[Math.floor(Math.random() * colorPresets.length)];
}
const defaultPreset = getRandomColorPreset();
let textColor = defaultPreset[1];
let bgColor = defaultPreset[0];
let tagName = "";
let error = "";
let submitDisabled = false;
let modalTitle = "Create A Tag";
let modalDesc = "Create a new tag";
let submitBtnText = "Create Tag";
async function addTag() {
console.debug("addTag:", tagName, textColor, bgColor);
if (!tagName) {
error = "Tag must have a name!";
return;
}
const nid = notify({ text: "Creating Tag", type: "loading" });
try {
const resp = await axios.post<Tag>("/tag", {
name: tagName,
color: textColor,
bgColor
} as TagAddRequest);
console.log("addTag: Tag was created", resp.data);
const _tags = get(tags);
_tags.push(resp.data);
tags.update((t) => t);
notify({ id: nid, text: "Tag Created!", type: "success" });
onClose();
} catch (err) {
console.error("addTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed!";
}
}
async function updateTag() {
console.debug("updateTag:", existingTag, tagName, textColor, bgColor);
if (!tagName) {
error = "Tag must have a name!";
return;
}
const nid = notify({ text: "Modifying Tag", type: "loading" });
try {
const resp = await axios.put<Tag>(`/tag/${existingTag!.id}`, {
name: tagName,
color: textColor,
bgColor
} as TagAddRequest);
console.log("updateTag: Tag was edited", resp.data);
existingTag!.name = tagName;
existingTag!.color = textColor;
existingTag!.bgColor = bgColor;
// Doesn't update `updatedAt`... may need to in the future if we need to sort by it, etc
tags.update((t) => t);
notify({ id: nid, text: "Tag Modified!", type: "success" });
onClose();
} catch (err) {
console.error("updateTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed!";
}
}
async function submitClicked() {
submitDisabled = true;
try {
if (existingTag) {
await updateTag();
} else {
await addTag();
}
} catch (err) {
console.log("CreateTagModal: Submit failed!", err);
}
submitDisabled = false;
}
onMount(() => {
if (existingTag) {
console.log("CreateTagModal: Entering edit mode for tag:", existingTag);
modalTitle = "Edit Tag";
modalDesc = "Edit an existing tag";
submitBtnText = "Edit Tag";
tagName = existingTag.name;
textColor = existingTag.color;
bgColor = existingTag.bgColor;
}
});
</script>
<div class="wrap">
<Modal title={modalTitle} desc={modalDesc} maxWidth="500px" {onClose} {error}>
<SettingsList>
<Setting title="Name" desc="What should we call this tag?">
<input type="text" name="name" placeholder="Name" bind:value={tagName} />
</Setting>
<Setting title="Text Color" desc="Color for your tags text." row>
<ColorSelector bind:value={textColor} style="max-width: 150px;" />
</Setting>
<Setting title="Background Color" desc="Color for your tags background." row>
<ColorSelector bind:value={bgColor} style="max-width: 150px;" />
</Setting>
<button class="add-tag-btn" on:click={() => submitClicked()} disabled={submitDisabled}>
{submitBtnText}
</button>
</SettingsList>
</Modal>
</div>
<style lang="scss">
.add-tag-btn {
width: max-content;
margin-left: auto;
}
.wrap {
color: $text-color;
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts">
import { tags, watchedList } from "@/store";
import Modal from "../Modal.svelte";
import Setting from "../settings/Setting.svelte";
import SettingsList from "../settings/SettingsList.svelte";
import ColorSelector from "../ColorSelector.svelte";
import { notify } from "../util/notify";
import axios from "axios";
import type { Tag as TagT, TagAddRequest } from "@/types";
import { get } from "svelte/store";
import Tag from "./Tag.svelte";
import { onMount } from "svelte";
export let tag: TagT;
export let onClose: () => void;
let error = "";
let deleteDisabled = false;
async function deleteTag() {
console.debug("deleteTag:", tag);
if (!tag || !tag.id) {
error = "Tag doesn't have an id!";
return;
}
deleteDisabled = true;
const nid = notify({ text: "Deleting Tag", type: "loading" });
try {
const resp = await axios.delete(`/tag/${tag.id}`);
console.log("deleteTag: Tag was deleted", resp.data);
// 1. Remove tag from store.
const _tags = get(tags);
tags.update((t) => t);
const newList = _tags.filter((t) => t.id !== tag.id);
tags.update(() => newList);
// 2. Remove tag from all watched entries in store.
try {
const wList = get(watchedList);
for (let i = 0; i < wList.length; i++) {
const wi = wList[i];
if (wi.tags && wi.tags.length > 0) {
wi.tags = wi.tags.filter((t) => t.id !== tag.id);
}
}
watchedList.update(() => wList);
} catch (err) {
console.error("deleteTag: Failed to remove tags from watched entries:", err);
}
notify({ id: nid, text: "Tag Deleted!", type: "success" });
onClose();
} catch (err) {
console.error("deleteTag: Failed!", err);
notify({ id: nid, text: "Failed!", type: "error", time: 1 });
error = "Failed To Delete!";
}
deleteDisabled = false;
}
onMount(() => {
// Sort of prevent accidental clickage, wait 3s after opening modal before enabling delete btn.
deleteDisabled = true;
setTimeout(() => {
deleteDisabled = false;
}, 3000);
});
</script>
<Modal
title="Permanently Delete Tag"
desc="Are you sure you want to delete this tag?"
maxWidth="500px"
{onClose}
{error}
>
<div class="inner">
<Tag {tag} />
<button class="delete-tag-btn" on:click={() => deleteTag()} disabled={deleteDisabled}>
Yes, fully delete (unrecoverable)
</button>
</div>
</Modal>
<style lang="scss">
.delete-tag-btn {
width: max-content;
margin-left: auto;
&:hover {
color: $error;
}
}
.inner {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 10px;
max-width: 100%;
width: 100%;
}
</style>
+48
View File
@@ -0,0 +1,48 @@
<script lang="ts">
import type { Tag } from "@/types";
export let tag: Tag;
export let onClick: () => void | undefined = undefined!;
let tagBtn: HTMLButtonElement;
$: {
if (tagBtn) {
if (tag.color) {
tagBtn.style.color = tag.color;
}
if (tag.bgColor) {
tagBtn.style.background = tag.bgColor;
}
}
}
</script>
<button
bind:this={tagBtn}
class={`plain`}
on:click={() => {
if (typeof onClick === "function") {
onClick();
}
}}
>
{tag.name}
</button>
<style lang="scss">
button {
text-transform: capitalize;
position: relative;
width: max-content;
border-radius: 8px;
padding: 5px 8px;
text-wrap: wrap;
word-break: break-word;
transition: opacity 150ms ease-in-out;
&:hover {
opacity: 0.8;
}
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import { tags } from "@/store";
import Icon from "../Icon.svelte";
import CreateTagModal from "./CreateTagModal.svelte";
import type { Tag as TagT } from "@/types";
import Tag from "./Tag.svelte";
import DeleteTagModal from "./DeleteTagModal.svelte";
export let titleText: string | undefined = undefined;
export let classes: string | undefined = undefined;
export let onTagClick: (tag: TagT, remove: boolean) => void | undefined = undefined!;
export let selectedTags: TagT[] | undefined = undefined;
/**
* When `showManageBtn` is true, a manage icon will appear at top
* of menu for the user to click. When toggled on, clicking a tag
* will trigger a deletion instead of `onTagClick()`.
*/
export let showManageBtn = false;
$: allTags = $tags;
let tagModalOpen = false;
let inManageMode = false;
let tagToDelete: TagT | undefined = undefined;
function deleteTag(t: TagT) {
// TODO 1. confirmation 2. run delete req 3. remove from local state & from watcheds state
tagToDelete = t;
}
</script>
<div class={[`menu`, classes].join(" ")}>
<div class="inner">
<div class="title">
<h4 class="norm sm-caps">{titleText ? titleText : "My Tags"}</h4>
{#if showManageBtn}
<button
class={["plain", inManageMode ? "manage-on" : ""].join(" ")}
on:click={() => (inManageMode = !inManageMode)}
>
<Icon i="trash" wh={18} />
</button>
{/if}
<button class="plain" on:click={() => (tagModalOpen = !tagModalOpen)}>
<Icon i="add" wh={22} />
</button>
</div>
{#if allTags && allTags.length > 0}
{#if inManageMode}
<strong style="font-size: 12px; margin-bottom: 10px;">Click a tag to delete it.</strong>
{/if}
<div class="list">
{#each allTags as t}
{@const isSelected = selectedTags
? selectedTags.find((tag) => tag.id === t.id)
? true
: false
: false}
<div>
<Tag
tag={t}
onClick={() => (inManageMode ? deleteTag(t) : onTagClick(t, isSelected))}
/>
{#if isSelected}
<Icon i="check" wh={18} />
{/if}
</div>
{/each}
</div>
{:else}
<span style="margin-top: 0;">You have no tags yet!</span>
{/if}
</div>
</div>
{#if tagModalOpen}
<CreateTagModal onClose={() => (tagModalOpen = false)} />
{/if}
{#if tagToDelete}
<DeleteTagModal tag={tagToDelete} onClose={() => (tagToDelete = undefined)} />
{/if}
<style lang="scss">
div.menu {
width: 200px;
right: 47px;
&:before {
left: 78px;
}
&.from-add-to-tag-btn {
top: 50px;
right: -78px;
&:before {
left: 87px;
/* The place where this button will be is always dark, so white works for both themes */
border-bottom-color: white;
}
}
}
div.inner {
h4 {
color: $text-color;
&:not(:first-of-type) {
margin-top: 8px;
}
}
.title {
display: flex;
flex-flow: row;
align-items: center;
margin-bottom: 8px;
gap: 5px;
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 26px;
padding: 2px 3px;
border-radius: 8px;
&.manage-on {
color: #f3555a;
background-color: $text-color;
}
&:first-of-type {
margin-left: auto;
}
}
}
.list {
display: flex;
flex-flow: column;
gap: 5px;
& > div {
display: flex;
align-items: center;
gap: 5px;
color: $text-color;
:global(svg) {
min-width: 18px;
}
}
}
}
</style>
+61
View File
@@ -0,0 +1,61 @@
import { watchedList } from "@/store";
import axios from "axios";
import { get } from "svelte/store";
import { notify } from "../util/notify";
import type { Tag } from "@/types";
export async function tagWatched(watchedId: number, tag: Tag): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedId);
const nid = notify({ text: `Tagging`, type: "loading" });
if (!wEntry) {
notify({ id: nid, text: "Failed To Tag! Watched entry not found.", type: "error" });
return false;
}
return await axios
.post(`/watched/${watchedId}/tag/${tag.id}`)
.then((resp) => {
console.log("tagWatched: Status:", resp.status);
if (!wEntry.tags) {
wEntry.tags = [tag];
} else {
wEntry.tags.push(tag);
}
watchedList.update(() => wList);
notify({ id: nid, text: `Tagged!`, type: "success" });
return true;
})
.catch((err) => {
console.error("tagWatched: Request failed!", err);
notify({ id: nid, text: "Failed To Tag!", type: "error" });
return false;
});
}
export async function untagWatched(watchedId: number, tag: Tag): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === watchedId);
const nid = notify({ text: `Untagging`, type: "loading" });
if (!wEntry) {
notify({ id: nid, text: "Failed To Untag! Watched entry not found.", type: "error" });
return false;
}
return await axios
.delete(`/watched/${watchedId}/tag/${tag.id}`)
.then((resp) => {
console.log("untagWatched: Status:", resp.status);
if (wEntry.tags) {
wEntry.tags = wEntry.tags.filter((t) => t.id !== tag.id);
}
watchedList.update(() => wList);
notify({ id: nid, text: `Untagged!`, type: "success" });
return true;
})
.catch((err) => {
console.error("untagWatched: Request failed!", err);
notify({ id: nid, text: "Failed To Untag!", type: "error" });
return false;
});
}
+40 -20
View File
@@ -16,33 +16,47 @@ import {
import axios from "axios";
import { get } from "svelte/store";
import { notify, unNotify } from "./notify";
import { browser } from "$app/environment";
const { MODE } = import.meta.env;
export const baseURL = MODE === "development" ? "http://127.0.0.1:3080/api" : "/api";
export const baseURL =
MODE === "development"
? browser
? `${location.protocol}//${location.hostname}:3080/api`
: "http://127.0.0.1:3080/api"
: "/api";
console.log("api: baseURL constructed:", baseURL);
/**
* Updates watched item with new status, rating or thoughts.
* @returns Was success?
*/
function _updateWatched(
async function _updateWatched(
wEntry: Watched,
status?: WatchedStatus,
rating?: number,
thoughts?: string
) {
thoughts?: string,
pinned?: boolean
): Promise<boolean> {
if (!status && !rating && typeof thoughts === "undefined" && typeof pinned === "undefined") {
console.warn("_updateWatched: Nothing was provided, so nothing can be updated!!!!");
return false;
}
const nid = notify({ text: `Saving`, type: "loading" });
if (!status && !rating && typeof thoughts === "undefined") return;
const obj = {} as WatchedUpdateRequest;
if (status) obj.status = status;
if (rating) obj.rating = rating;
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
if (thoughts === "") obj.removeThoughts = true;
axios
if (typeof pinned !== "undefined") obj.pinned = pinned;
return await axios
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
.then((resp) => {
if (status) wEntry.status = status;
if (rating) wEntry.rating = rating;
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
if (resp?.data?.newActivity) {
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
if (wEntry.activity?.length > 0) {
wEntry.activity.push(resp.data.newActivity);
} else {
@@ -55,10 +69,12 @@ function _updateWatched(
}
watchedList.update((w) => w);
notify({ id: nid, text: `Saved!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Update!", type: "error" });
return false;
});
}
@@ -68,27 +84,27 @@ function _updateWatched(
* @param contentType show/movie
* @param status
* @param rating
* @returns
* @returns Was success?
*/
export function updateWatched(
export async function updateWatched(
contentId: number,
contentType: MediaType,
status?: WatchedStatus,
rating?: number,
thoughts?: string
) {
thoughts?: string,
pinned?: boolean
): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find(
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType
);
if (wEntry?.id) {
_updateWatched(wEntry, status, rating, thoughts);
return;
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
}
// Add new watched item
const nid = notify({ text: `Adding`, type: "loading" });
axios
return await axios
.post("/watched", {
contentId,
contentType,
@@ -100,10 +116,12 @@ export function updateWatched(
wList.push(resp.data as Watched);
watchedList.update(() => wList);
notify({ id: nid, text: `Added!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Add!", type: "error" });
return false;
});
}
@@ -134,22 +152,22 @@ export function removeWatched(id: number) {
});
}
export function updatePlayed(
export async function updatePlayed(
igdbId: number,
status?: WatchedStatus,
rating?: number,
thoughts?: string
) {
thoughts?: string,
pinned?: boolean
): Promise<boolean> {
// If item is already in watched store, run update request instead
const wList = get(watchedList);
const wEntry = wList.find((w) => w.game?.igdbId === igdbId);
if (wEntry?.id) {
_updateWatched(wEntry, status, rating, thoughts);
return;
return await _updateWatched(wEntry, status, rating, thoughts, pinned);
}
// Add new played item
const nid = notify({ text: `Adding`, type: "loading" });
axios
return await axios
.post("/game/played", {
igdbId,
rating,
@@ -160,10 +178,12 @@ export function updatePlayed(
wList.push(resp.data as Watched);
watchedList.update(() => wList);
notify({ id: nid, text: `Added!`, type: "success" });
return true;
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed To Add!", type: "error" });
return false;
});
}
+131 -2
View File
@@ -7,7 +7,9 @@ import {
type Theme,
type TokenClaims,
type Watched,
type WatchedStatus
type WatchedStatus,
type WatchedEpisode,
type WatchedSeason
} from "@/types";
export const watchedStatuses: {
@@ -63,7 +65,12 @@ export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Wat
return {
id: wel.id,
status: wel.status,
rating: wel.rating
rating: wel.rating,
extraDetails: {
dateAdded: wel.createdAt,
dateModified: wel.updatedAt,
lastWatched: getLatestWatchedInTv(wel.watchedSeasons, wel.watchedEpisodes)
}
};
}
@@ -77,6 +84,82 @@ export function getPlayedDependedProps(wid: number, list: Watched[]) {
};
}
// Get biggest season watching or biggest season watched.
// This could probably be simpler but -_-
export function getLatestWatchedInTv(
ws: WatchedSeason[] | undefined,
we: WatchedEpisode[] | undefined
): string {
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
return "";
}
let biggestSeasonWatched = -1;
let biggestSeasonWatching = -1;
if (ws && ws.length > 0) {
for (let i = 0; i < ws.length; i++) {
const s = ws[i];
if (s.status === "WATCHING") {
if (s.seasonNumber > biggestSeasonWatching) {
biggestSeasonWatching = s.seasonNumber;
}
} else if (s.status === "FINISHED") {
if (s.seasonNumber > biggestSeasonWatched) {
biggestSeasonWatched = s.seasonNumber;
}
}
}
}
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
// Look for biggest watched/watching episode in season if any.
// Does same thing as above.
let episode: WatchedEpisode | undefined;
if (we && we.length > 0) {
let biggestEpisodeWatched: WatchedEpisode | undefined;
let biggestEpisodeWatching: WatchedEpisode | undefined;
for (let i = 0; i < we.length; i++) {
const s = we[i];
if (season >= 0 && s.seasonNumber !== season) continue;
if (s.status === "WATCHING") {
if (!biggestEpisodeWatching) {
biggestEpisodeWatching = s;
}
if (
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
) {
biggestEpisodeWatching = s;
}
} else if (s.status === "FINISHED") {
if (!biggestEpisodeWatched) {
biggestEpisodeWatched = s;
}
if (
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
) {
biggestEpisodeWatched = s;
}
}
}
if (biggestEpisodeWatched || biggestEpisodeWatching) {
episode =
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
}
}
if (season >= 0 && episode) {
return seasonAndEpToReadable(season, episode.episodeNumber);
} else if (season >= 0) {
return `Season ${season}`;
} else if (episode) {
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
} else {
return "";
}
}
/**
* Turn a watched status into understandable text
* depending on if the status is for a game or not.
@@ -238,3 +321,49 @@ export function userHasPermission(perms: UserPermission, reqPerm: UserPermission
export function seasonAndEpToReadable(season: number | undefined, episode: number | undefined) {
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
}
export function msToAmountsOfTime(ms: number) {
const seconds = Math.floor((ms / 1000) % 60);
const minutes = Math.floor((ms / (1000 * 60)) % 60);
const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
const days = Math.floor(ms / (1000 * 60 * 60 * 24));
return { days, hours, minutes, seconds };
}
/** I'm no warden of time, this is as relative as it's getting. */
export function toRelativeDate(d: Date): string {
if (!d) {
return "Unknown";
}
const dn = new Date(Date.now());
if (d.getFullYear() === dn.getFullYear()) {
if (d.getMonth() === dn.getMonth()) {
if (d.getDate() === dn.getDate()) {
return "Today";
}
}
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]}`;
}
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} ${d.getFullYear()}`;
}
/**
* To relative time (seconds, mins, hours).
* @param s Seconds.
*/
export function toRelativeTime(s: number) {
const totalMinutes = Math.floor(s / 60);
const hours = Math.floor(totalMinutes / 60);
const mins = totalMinutes % 60;
const secs = Math.floor(s % 60);
if (hours > 0) {
return `${hours} hours`;
}
if (mins > 0) {
return `${mins} minutes`;
}
if (secs > 0) {
return `${secs} seconds`;
}
return "now";
}
+93
View File
@@ -333,3 +333,96 @@
}
}
}
:global(table) {
margin-top: 20px;
width: 100%;
border-spacing: 0px;
border: 1px solid $accent-color;
border-radius: 10px;
font-size: 16px;
th {
padding: 12px 15px;
text-align: left;
transition: padding 100ms ease;
&:first-of-type {
border-top-left-radius: 10px;
}
&:last-of-type {
border-top-right-radius: 10px;
}
&.loading-col {
width: 28px;
padding: 0;
}
}
tr {
th {
background-color: $accent-color;
}
&:last-child {
td:first-of-type {
border-bottom-left-radius: 10px;
}
td:last-of-type {
border-bottom-right-radius: 10px;
}
}
&:nth-child(odd) td {
background-color: $accent-color;
}
}
td {
padding: 5px;
&.icon-cell {
padding-right: 3px;
& > div {
display: flex;
padding-left: 4px;
}
}
input {
background: transparent;
color: $text-color;
border: 0;
font-size: 16px;
padding: 0;
padding: 7px 10px;
transition: padding 100ms ease;
&[type="number"] {
appearance: textfield;
&::-webkit-outer-spin-button,
&::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
}
}
button {
&.delete {
display: flex;
justify-content: center;
color: $placeholder-color;
&:hover {
color: $error;
}
}
}
}
}
+303 -121
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { afterNavigate, goto } from "$app/navigation";
import { page } from "$app/stores";
import Icon from "@/lib/Icon.svelte";
import PageError from "@/lib/PageError.svelte";
@@ -9,6 +9,7 @@
import FilterMenu from "@/lib/nav/FilterMenu.svelte";
import FollowingMenu from "@/lib/nav/FollowingMenu.svelte";
import SortMenu from "@/lib/nav/SortMenu.svelte";
import TagMenu from "@/lib/tag/TagMenu.svelte";
import { isTouch, parseTokenPayload, userHasPermission } from "@/lib/util/helpers";
import { notify } from "@/lib/util/notify";
import {
@@ -19,22 +20,24 @@
follows,
searchQuery,
serverFeatures,
tags,
userInfo,
userSettings,
watchedList
} from "@/store";
import { type Filters, UserPermission } from "@/types";
import { UserPermission } from "@/types";
import axios from "axios";
import { onMount } from "svelte";
import { get } from "svelte/store";
let navEl: HTMLElement;
let mainSearchEl: HTMLInputElement;
let searchTimeout: NodeJS.Timeout;
let subMenuShown = false;
let filterMenuShown = false;
let sortMenuShown = false;
let followingMenuShown = false;
let detailedMenuShown = false;
let tagMenuShown = false;
$: settings = $userSettings;
$: user = $userInfo;
@@ -72,7 +75,13 @@
// https://github.com/sbondCo/Watcharr/issues/169
target.autofocus = true;
goto(`/search/${query}`).then(() => {
target?.focus();
// Use mainSearchEl if nav not split, otherwise use ev target.
if (!document.body.classList.contains("split-nav")) {
mainSearchEl?.focus();
mainSearchEl.autofocus = false;
} else {
target?.focus();
}
target.autofocus = false;
});
}
@@ -97,6 +106,16 @@
subMenuShown = false;
}
function userManagement() {
goto("/manage_users");
subMenuShown = false;
}
function requestManagement() {
goto("/arr_requests");
subMenuShown = false;
}
function shareWatchedList() {
const nid = notify({ type: "loading", text: "Getting link" });
const ud = parseTokenPayload();
@@ -124,12 +143,13 @@
async function getInitialData() {
if (localStorage.getItem("token")) {
const [w, u, s, f, fo] = await Promise.all([
const [w, u, s, f, fo, ts] = await Promise.all([
axios.get("/watched"),
axios.get("/user"),
axios.get("/user/settings"),
axios.get("/features"),
axios.get("/follow")
axios.get("/follow"),
axios.get("/tag")
]);
if (w?.data?.length > 0) {
watchedList.update((wl) => (wl = w.data));
@@ -146,6 +166,9 @@
if (fo?.data) {
follows.update((f) => (f = fo.data));
}
if (ts?.data) {
tags.update((t) => (t = ts.data));
}
} else {
goto("/login?again=1");
}
@@ -157,10 +180,42 @@
if (except !== "sort") sortMenuShown = false;
if (except !== "following") followingMenuShown = false;
if (except !== "detailed") detailedMenuShown = false;
if (except !== "tag") tagMenuShown = false;
}
/**
* Adds or removed `split-nav` tag to body depending
* on how big the main search bar is.
*/
function decideOnNavSplit() {
if (window.innerWidth <= 305) {
document.body.classList.add("split-nav");
return;
}
const bigInput = navEl?.querySelector("input:not(.small)");
if (bigInput) {
const b = bigInput.getBoundingClientRect();
// console.debug("decideOnNavSplit: bigInput bounds:", b);
if (b.width <= 45) {
document.body.classList.add("split-nav");
} else {
document.body.classList.remove("split-nav");
}
} else {
console.warn("decideOnNavSplit: bigInput not found!", bigInput);
}
}
afterNavigate(() => {
decideOnNavSplit();
closeAllSubMenus();
});
onMount(() => {
if (navEl) {
decideOnNavSplit();
window.addEventListener("resize", decideOnNavSplit);
let scroll = window.scrollY;
window.document.addEventListener("scroll", (ev: Event) => {
if (scroll > window.scrollY) {
@@ -180,108 +235,150 @@
</script>
<nav bind:this={navEl}>
<a href="/">
<span class="large">Watcharr</span>
<span class="small">W</span>
</a>
<input type="text" placeholder="Search" bind:value={$searchQuery} on:keydown={handleSearch} />
<div class="btns">
<!-- Detailed posters only supported on own watched list currently -->
{#if $page.url?.pathname === "/"}
<button
class="plain other detailedView"
on:click={() => {
closeAllSubMenus("detailed");
detailedMenuShown = !detailedMenuShown;
}}
use:tooltip={{ text: "Detailed View", pos: "bot", condition: !detailedMenuShown }}
>
<Icon i="eye" />
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
<div class="indicator"></div>
<div class="wrapper">
<a href="/">
<span class="large">Watcharr</span>
<span class="small">W</span>
</a>
<div class="search">
<input
bind:this={mainSearchEl}
type="text"
placeholder="Search"
bind:value={$searchQuery}
on:keydown={handleSearch}
/>
<Icon i="search" wh={19} />
</div>
<div class="btns">
<!-- Detailed posters only supported on own watched list currently -->
{#if $page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")}
<button
class="plain other detailedView"
on:click={() => {
closeAllSubMenus("detailed");
detailedMenuShown = !detailedMenuShown;
}}
use:tooltip={{ text: "Detailed View", pos: "bot", condition: !detailedMenuShown }}
>
<Icon i="eye" />
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
<div class="indicator"></div>
{/if}
</button>
{#if detailedMenuShown}
<DetailedMenu />
{/if}
</button>
{#if detailedMenuShown}
<DetailedMenu />
{/if}
{/if}
<!-- Show on watched list and shared/followed watched lists -->
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
<button
class="plain other filter"
on:click={() => {
closeAllSubMenus("filter");
filterMenuShown = !filterMenuShown;
}}
use:tooltip={{ text: "Filter", pos: "bot", condition: !filterMenuShown }}
>
<Icon i="filter" />
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
<div class="indicator"></div>
<!-- Show on watched list and shared/followed watched lists -->
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
<button
class="plain other sort"
on:click={() => {
closeAllSubMenus("sort");
sortMenuShown = !sortMenuShown;
}}
use:tooltip={{ text: "Sort", pos: "bot", condition: !sortMenuShown }}
>
<Icon i="sort" />
<!-- Show indicator if not equal to default and second item in array is not falsy -->
{#if $activeSort?.length === 2 && $activeSort[1] && JSON.stringify($activeSort) !== JSON.stringify(defaultSort)}
<div class="indicator"></div>
{/if}
</button>
<button
class="plain other filter"
on:click={() => {
closeAllSubMenus("filter");
filterMenuShown = !filterMenuShown;
}}
use:tooltip={{ text: "Filter", pos: "bot", condition: !filterMenuShown }}
>
<Icon i="filter" />
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
<div class="indicator"></div>
{/if}
</button>
{#if sortMenuShown}
<SortMenu />
{/if}
{#if filterMenuShown}
<FilterMenu />
{/if}
{/if}
<button
class="plain other tag"
on:click={() => {
closeAllSubMenus("tag");
tagMenuShown = !tagMenuShown;
}}
use:tooltip={{ text: "Tags", pos: "bot", condition: !tagMenuShown }}
>
<Icon i="tag" />
</button>
{#if tagMenuShown}
<TagMenu
onTagClick={(tag) => {
goto(`/tag/${tag.id}`);
tagMenuShown = false;
}}
showManageBtn={true}
/>
{/if}
<button
class="plain other discover"
on:click={() => goto("/discover")}
use:tooltip={{ text: "Discover", pos: "bot" }}
>
<Icon i="compass" wh={26} />
</button>
<button
class="plain other sort"
class="plain other following"
on:click={() => {
closeAllSubMenus("sort");
sortMenuShown = !sortMenuShown;
closeAllSubMenus("following");
followingMenuShown = !followingMenuShown;
}}
use:tooltip={{ text: "Sort", pos: "bot", condition: !sortMenuShown }}
use:tooltip={{ text: "Following", pos: "bot", condition: !followingMenuShown }}
>
<Icon i="sort" />
<!-- Show indicator if not equal to default and second item in array is not falsy -->
{#if $activeSort?.length === 2 && $activeSort[1] && JSON.stringify($activeSort) !== JSON.stringify(defaultSort)}
<div class="indicator"></div>
{/if}
<Icon i="people" wh={26} />
</button>
{#if sortMenuShown}
<SortMenu />
{#if followingMenuShown}
<FollowingMenu close={() => (followingMenuShown = false)} />
{/if}
{#if filterMenuShown}
<FilterMenu />
{/if}
{/if}
<button
class="plain other discover"
on:click={() => goto("/discover")}
use:tooltip={{ text: "Discover", pos: "bot" }}
>
<Icon i="compass" wh={26} />
</button>
<button
class="plain other following"
on:click={() => {
closeAllSubMenus("following");
followingMenuShown = !followingMenuShown;
}}
use:tooltip={{ text: "Following", pos: "bot", condition: !followingMenuShown }}
>
<Icon i="people" wh={26} />
</button>
{#if followingMenuShown}
<FollowingMenu close={() => (followingMenuShown = false)} />
{/if}
<button class="plain face" on:click={handleProfileClick}>:)</button>
{#if subMenuShown}
<div class="menu face-menu">
<div>
{#if user?.username}
<h5 title={user.username}>Hi {user.username}!</h5>
{/if}
<button class="plain" on:click={() => profile()}>Profile</button>
{#if !settings?.private}
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
{/if}
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
<button class="plain" on:click={() => serverSettings()}>Settings</button>
{/if}
<button class="plain" on:click={() => logout()}>Logout</button>
<!-- svelte-ignore missing-declaration -->
<span>v{__WATCHARR_VERSION__}</span>
<button class="plain face" on:click={handleProfileClick}>:)</button>
{#if subMenuShown}
<div class="menu face-menu">
<div>
{#if user?.username}
<h5 title={user.username}>Hi {user.username}!</h5>
{/if}
<button class="plain" on:click={() => profile()}>Profile</button>
{#if !settings?.private}
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
{/if}
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
<button class="plain" on:click={() => serverSettings()}>Settings</button>
<button class="plain" on:click={() => userManagement()}>Users</button>
{#if $serverFeatures.sonarr || $serverFeatures.radarr}
<!-- At least one (sonarr/radarr) should be enabled for requests menu item to display. -->
<button class="plain" on:click={() => requestManagement()}>Requests</button>
{/if}
{/if}
<button class="plain" on:click={() => logout()}>Logout</button>
<!-- svelte-ignore missing-declaration -->
<span>v{__WATCHARR_VERSION__}</span>
</div>
</div>
</div>
{/if}
{/if}
</div>
</div>
<input
class="small"
type="text"
placeholder="Search"
bind:value={$searchQuery}
on:keydown={handleSearch}
/>
</nav>
{#await getInitialData()}
@@ -295,20 +392,50 @@
<style lang="scss">
nav {
display: flex;
align-items: center;
justify-content: space-between;
flex-flow: column;
margin-bottom: 20px;
padding: 10px 20px;
position: sticky;
top: 0;
gap: 20px;
gap: 3px;
z-index: 99990;
backdrop-filter: blur(2.5px) saturate(120%);
background-color: $nav-color;
transition: top 200ms ease-in-out;
&:global(.scrolled-down) {
top: -71px;
top: -110px;
}
.wrapper {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: space-between;
align-items: center;
@media screen and (max-width: 425px) {
gap: 15px;
}
/* Slowly decrease the gap to ensure the main search bar doesn't get big enough again and pop back up in the nav. */
body.split-nav & {
@media screen and (max-width: 380px) {
gap: 10px;
}
@media screen and (max-width: 375px) {
gap: 8px;
}
@media screen and (max-width: 370px) {
gap: 5px;
}
@media screen and (max-width: 350px) {
gap: 0;
}
}
}
a {
@@ -351,19 +478,90 @@
}
}
.search {
width: 100%;
position: relative;
// Make the box look a little more centered, inline with the rest of the nav items.
margin-bottom: 2px;
:global(svg) {
display: none;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
user-select: none;
}
input:focus-within + :global(svg),
input:not(:placeholder-shown) + :global(svg) {
display: none;
}
@media screen and (min-width: 666px) {
max-width: 250px;
}
@media screen and (max-width: 666px) {
& input:not(.small) {
width: 100%;
}
&:focus-within + .btns button:not(.face) {
display: none;
}
}
@media screen and (max-width: 415px) {
:global(svg) {
display: block;
}
input::placeholder {
color: transparent;
}
}
}
body.split-nav & {
.search {
opacity: 0;
visibility: hidden;
}
input.small {
display: block;
}
}
input {
width: 250px;
width: 100%;
font-weight: bold;
text-align: center;
box-shadow: 4px 4px 0px 0px $text-color;
text-overflow: ellipsis;
transition:
width 150ms ease,
box-shadow 150ms ease;
&.small {
display: none;
margin-left: auto;
margin-right: auto;
}
&:hover,
&:focus {
box-shadow: 2px 2px 0px 0px $text-color;
}
@media screen and (max-width: 290px) {
&.small {
width: 100%;
}
}
}
.btns {
@@ -391,12 +589,7 @@
}
}
button.detailedView {
margin-right: 15px;
}
button.filter {
margin-right: 15px;
&:hover,
&:focus-visible {
:global(path) {
@@ -405,10 +598,6 @@
}
}
button.sort {
margin-right: 12px;
}
button.filter,
button.sort {
position: relative;
@@ -425,7 +614,6 @@
}
button.discover {
margin-right: 12px;
transition:
fill 150ms ease,
stroke 150ms ease,
@@ -438,6 +626,10 @@
}
}
& > button:not(.face) {
margin-right: 12px;
}
button.following {
margin-right: 17px;
}
@@ -469,15 +661,5 @@
}
}
}
@media screen and (max-width: 666px) {
input {
width: 100%;
&:focus + .btns button:not(.face) {
display: none;
}
}
}
}
</style>

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