Compare commits

...

319 Commits

Author SHA1 Message Date
IRHM a3abc549b1 v1.20.1 2023-10-28 04:13:49 +01:00
IRHM 3dfb88bfb4 1.20.1-dev1 2023-10-28 04:02:25 +01:00
Mr 731ebce307 Merge pull request #172 from sbondCo/chrome-issues
Poster: Fix big scrollbars making icons small in sub menus on chromium
2023-10-28 03:01:07 +00:00
IRHM 1eacde4555 Poster: Fix big scrollbars making icons small in sub menus in chromium 2023-10-28 03:01:08 +01:00
IRHM d48e7286d8 1.20.1-dev 2023-10-27 21:48:22 +01:00
Mr 27f081a8c6 Merge pull request #162 from sbondCo/dependabot/npm_and_yarn/typescript-eslint/parser-6.9.0
ui: bump @typescript-eslint/parser from 6.8.0 to 6.9.0
2023-10-27 20:39:21 +00:00
Mr 17d1a33867 Merge pull request #163 from sbondCo/dependabot/npm_and_yarn/vite-4.5.0
ui: bump vite from 4.4.11 to 4.5.0
2023-10-27 20:39:07 +00:00
Mr 8acc07cb94 Merge pull request #164 from sbondCo/dependabot/npm_and_yarn/svelte-4.2.2
ui: bump svelte from 4.2.1 to 4.2.2
2023-10-27 20:38:54 +00:00
dependabot[bot] 7335c83a08 ui: bump svelte from 4.2.1 to 4.2.2
Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 4.2.1 to 4.2.2.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/master/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@4.2.2/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>
2023-10-27 20:36:53 +00:00
dependabot[bot] 695aa5bb0b ui: bump @typescript-eslint/parser from 6.8.0 to 6.9.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.8.0 to 6.9.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.9.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>
2023-10-27 20:36:52 +00:00
Mr f7685f22c6 Merge pull request #165 from sbondCo/dependabot/npm_and_yarn/eslint-8.52.0
ui: bump eslint from 8.51.0 to 8.52.0
2023-10-27 20:36:24 +00:00
Mr 952ef27781 Merge pull request #166 from sbondCo/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-6.9.0
ui: bump @typescript-eslint/eslint-plugin from 6.8.0 to 6.9.0
2023-10-27 20:36:07 +00:00
Mr e8f186fd54 Merge pull request #171 from sbondCo/search-issue
Nav: Fix search input not re-focusing after search loads on chromium
2023-10-27 20:34:55 +00:00
IRHM d0a964775e nav: fix search unfocusing on chromium 2023-10-27 21:30:19 +01:00
IRHM 866432d22e Revert "nav: fix searchbar un-focusing on chrome"
This reverts commit 5dce12c73b.
2023-10-27 21:13:51 +01:00
IRHM 5dce12c73b nav: fix searchbar un-focusing on chrome
autofocus fixes this for us now, we won't use it anywhere else.
2023-10-27 21:11:51 +01:00
IRHM 6d002b4d1a nav: add ? operator to classList usage
when chrome doesnt focus input and you press space, it will scroll down, for some reason that will fail too.
2023-10-27 20:10:08 +01:00
dependabot[bot] 5a66b51b00 ui: bump @typescript-eslint/eslint-plugin from 6.8.0 to 6.9.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.8.0 to 6.9.0.
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v6.9.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>
2023-10-23 19:51:58 +00:00
dependabot[bot] 16d53be62c ui: bump eslint from 8.51.0 to 8.52.0
Bumps [eslint](https://github.com/eslint/eslint) from 8.51.0 to 8.52.0.
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v8.51.0...v8.52.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-23 19:51:24 +00:00
dependabot[bot] afe1947bd3 ui: bump vite from 4.4.11 to 4.5.0
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.4.11 to 4.5.0.
- [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/v4.5.0/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>
2023-10-23 19:50:54 +00:00
Mr faff9ff366 Merge pull request #160 from sbondCo/dependabot/npm_and_yarn/typescript-eslint/parser-6.8.0
ui: bump @typescript-eslint/parser from 6.7.5 to 6.8.0
2023-10-16 19:37:23 +00:00
dependabot[bot] e7b0d4f3d1 ui: bump @typescript-eslint/parser from 6.7.5 to 6.8.0
Bumps [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) from 6.7.5 to 6.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/v6.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>
2023-10-16 19:36:06 +00:00
Mr a8650a47a5 Merge pull request #159 from sbondCo/dependabot/npm_and_yarn/typescript-eslint/eslint-plugin-6.8.0
ui: bump @typescript-eslint/eslint-plugin from 6.7.5 to 6.8.0
2023-10-16 19:35:04 +00:00
dependabot[bot] 1a0319f316 ui: bump @typescript-eslint/eslint-plugin from 6.7.5 to 6.8.0
Bumps [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) from 6.7.5 to 6.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/v6.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>
2023-10-16 19:33:36 +00:00
IRHM fc943d2eea v1.20.0 2023-10-15 19:29:36 +01:00
IRHM edc292be19 v1.20.0-dev 2023-10-15 19:18:43 +01:00
Mr 01d78695a8 Merge pull request #158 from sbondCo/similar-content
Similar Content Section
2023-10-15 14:26:34 +00:00
IRHM be14a4689c tv: fix PageError error prop 2023-10-15 15:19:21 +01:00
IRHM aea4aab78a Show similar content
had to rework content pages to allow for navigating to another show/movie, since sveltekit wont refresh the whole page when only changing a page parameter
2023-10-15 15:16:12 +01:00
Mr 0619338a97 Merge pull request #157 from sbondCo/fix-cast-text
Content: Fix `cast` header text scrolling out of view
2023-10-15 02:13:06 +00:00
IRHM 41677ea717 SeasonsList: Put max-width on season names
Some shows give seasons names, which when long, would break the episode view.
2023-10-15 02:58:49 +01:00
IRHM 6304c33436 Fix Cast header scrolling out of view
Moved styling for scroll into HorizontalList component.
2023-10-15 02:55:46 +01:00
Mr 41af9c9fe1 Merge pull request #156 from sbondCo/spoilerz
New `Hide Spoilers` Setting
2023-10-15 01:44:41 +00:00
IRHM bade703b4d SeasonsListEpisode: Add transition to blur 2023-10-15 02:40:27 +01:00
IRHM 7220684a97 SeasonsListEpisode: Support hiding spoilers
Hide episode image and name until user reveals
2023-10-15 02:34:13 +01:00
IRHM da7f62af12 SeasonsList: Move episode li out to its own component 2023-10-15 01:54:09 +01:00
IRHM 9054729069 server: Support multiple settings in userUpdate, add spoiler setting
and technically ui but wutevr
2023-10-14 22:29:21 +01:00
Mr 444105955e Merge pull request #155 from sbondCo/dependabot/github_actions/docker/build-push-action-5
workflow: bump docker/build-push-action from 4 to 5
2023-10-14 20:41:32 +00:00
Mr a23bfc5fac Merge pull request #153 from sbondCo/dependabot/github_actions/docker/login-action-3
workflow: bump docker/login-action from 2 to 3
2023-10-14 20:40:57 +00:00
Mr 0934f57fdb Merge pull request #152 from sbondCo/dependabot/github_actions/docker/metadata-action-5
workflow: bump docker/metadata-action from 4 to 5
2023-10-14 20:40:08 +00:00
dependabot[bot] 4669994b4a workflow: bump docker/login-action from 2 to 3
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-14 20:37:06 +00:00
Mr 1135e1132a Merge pull request #154 from sbondCo/dependabot/github_actions/actions/checkout-4
workflow: bump actions/checkout from 3 to 4
2023-10-14 20:36:30 +00:00
dependabot[bot] a22c830357 workflow: bump docker/build-push-action from 4 to 5
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
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>
2023-10-14 20:29:44 +00:00
dependabot[bot] f09005d171 workflow: bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-14 20:29:41 +00:00
dependabot[bot] 459c729da1 workflow: bump docker/metadata-action from 4 to 5
Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 4 to 5.
- [Release notes](https://github.com/docker/metadata-action/releases)
- [Upgrade guide](https://github.com/docker/metadata-action/blob/master/UPGRADE.md)
- [Commits](https://github.com/docker/metadata-action/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-14 20:29:37 +00:00
Mr aacdfebdcf Create dependabot.yml config 2023-10-14 20:29:19 +00:00
IRHM 41ad80c1a9 v1.19.1 2023-10-14 16:22:05 +01:00
IRHM a472b18981 1.19.1-dev 2023-10-14 15:43:38 +01:00
Mr 7d92901f80 Merge pull request #151 from sbondCo/update-deps
Update deps
2023-10-14 14:35:14 +00:00
IRHM 0a83567c11 doc: Update deps 2023-10-14 15:21:37 +01:00
IRHM 007a87b842 ui: update deps 2023-10-14 15:01:41 +01:00
IRHM cbac14f77e server: Update deps 2023-10-14 14:52:50 +01:00
Mr a6fcd93244 Merge pull request #150 from sbondCo/imgbot
[ImgBot] Optimize images
2023-10-13 22:55:56 +00:00
ImgBotApp 2801c5540f [ImgBot] Optimize images
*Total -- 1,748.69kb -> 1,661.01kb (5.01%)

/doc/static/img/social-card.png -- 58.22kb -> 46.23kb (20.59%)
/doc/docs/usage/img/content-full-page.png -- 331.69kb -> 305.20kb (7.99%)
/doc/docs/usage/img/search-for-content-hover-poster.png -- 334.93kb -> 321.19kb (4.1%)
/doc/docs/usage/img/search-for-content-hover-poster-status.png -- 329.38kb -> 316.68kb (3.86%)
/doc/docs/usage/img/search-for-content-hover-poster-rate.png -- 313.64kb -> 302.67kb (3.5%)
/doc/docs/usage/img/search-for-content.png -- 380.83kb -> 369.03kb (3.1%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2023-10-13 02:08:43 +00:00
Mr 20b3a2631a Merge pull request #149 from sbondCo/finish-docs
from source installation and adding to list guides
2023-10-13 00:56:07 +00:00
IRHM 3a7c6c07d5 test-docs workflow: Add docs path scope 2023-10-13 01:54:23 +01:00
IRHM 6b8d686dae docs: Remove docusaurus icons, create watcharr social card 2023-10-13 01:44:15 +01:00
IRHM 83d869417b docs: intro: Link to add to watchlist guide for general usage 2023-10-13 01:13:21 +01:00
IRHM 9bf216537c docs: usage: Create adding to watchlist guide 2023-10-13 01:10:17 +01:00
IRHM 611b2bbd43 docs: Disable dark theme toggle
not setup to support dark theme currently
2023-10-13 00:43:04 +01:00
IRHM 2b35e0b270 docs: installation: Add warnings 2023-10-13 00:38:37 +01:00
IRHM 7e57e50b99 docs: installation/from-source.md: Change tag to <tag> placeholder
Incase people are blindly copy pasting the commands, at least with this, the command will fail, instead of putting them on v1.19.0
2023-10-13 00:34:46 +01:00
IRHM 0703a511c0 docs: installation/from-source.md: Rough steps 2023-10-13 00:09:11 +01:00
IRHM fba76e38fb docs: homepage: Add description padding and center text 2023-10-12 22:35:46 +01:00
IRHM 7960876873 publish-docs: Fix publish_dir
think it is from root instead of the working-directory
2023-10-12 00:40:27 +01:00
IRHM 2395908c20 publish-docs: Add cname 2023-10-12 00:26:06 +01:00
IRHM a3e8877199 docs: no change 2023-10-12 00:16:57 +01:00
Mr bca9336d4f Merge pull request #148 from sbondCo/docs
Create docs
2023-10-11 22:38:39 +00:00
IRHM 4565af412b docs: add workflows 2023-10-11 23:06:58 +01:00
IRHM 232d704d9f docs: Fix homepage get started link 2023-10-11 20:47:44 +01:00
IRHM 869b13f473 docs: Create usage section 2023-10-11 20:46:55 +01:00
IRHM b28e06f559 Create docusaurus project
Change homepage and added docker compose install steps. Still a wip.
2023-10-10 22:55:55 +01:00
IRHM 3af9177849 v1.19.0 2023-10-09 16:28:45 +01:00
Mr f83ec47bc3 Merge pull request #147 from sbondCo/v1.19
v1.19.0 - Updated readme steps & initial config setup fix
2023-10-09 15:27:16 +00:00
IRHM 4cda259a2a remove .env volume from docker-compose files 2023-10-09 16:26:13 +01:00
IRHM 4a6932f5b1 README: Remove .env volume from example compose file 2023-10-09 16:25:48 +01:00
IRHM 0b508772b8 v1.19.0-dev1 2023-10-09 15:59:05 +01:00
IRHM e5dfe96186 config: don't overwrite whole Config object when generating jwt_secret
So that the default options are not overridden (in this case SIGNUP_ENABLED was being reset back to false)
2023-10-09 15:41:06 +01:00
IRHM 1a20e1cdd9 README: Update setup guide to remove .env section 2023-10-09 14:18:37 +01:00
IRHM 6f47c291cb v1.19.0-dev 2023-10-09 14:18:37 +01:00
Mr 79a64fd667 Merge pull request #146 from sbondCo/update-userinfo-after-get-admin
Update userinfo after get admin
2023-10-09 02:30:51 +00:00
IRHM dba92dc3e0 request_admin: Fix instructions typo 2023-10-09 03:29:11 +01:00
IRHM f79393d072 useAdminToken: Update userInfo permissions state on success 2023-10-09 03:26:09 +01:00
Mr 350c6db596 Merge pull request #145 from sbondCo/settings-fix-empty-input
config: Fix panic if `v` unset in `updateConfig`
2023-10-09 02:14:11 +00:00
IRHM b9c0a9f700 config: Fix panic if v unset in updateConfig 2023-10-09 03:12:49 +01:00
Mr 03572c1324 Merge pull request #144 from sbondCo/ui-settings
Web UI Server Settings
2023-10-09 01:21:50 +00:00
IRHM 1eef015e5e config: remove test log 2023-10-09 02:05:20 +01:00
IRHM 00d284bb07 Nav: Close subMenu after clicking settings 2023-10-09 02:05:10 +01:00
IRHM 56b44bd9ab server settings: Add warning to jellyfin host 2023-10-09 01:49:23 +01:00
IRHM 170c96ac8a config: Remove TMDB_KEY variable and make getTMDBKey func 2023-10-09 01:42:10 +01:00
IRHM 416ce62081 server settings: Make jellyfin host editable
editable through web ui with live change.

AvailableAuthProviders moved into /auth/available route and is filled out on each request
2023-10-08 23:25:30 +01:00
IRHM 06bfae19ed Update logging level when changed from ui 2023-10-08 20:19:16 +01:00
IRHM c3b0411bb5 Server Settings: Support updating jellyfin host and tmdb key 2023-10-08 04:30:08 +01:00
IRHM ba3b8d9281 Checkbox: Add name prop
So we can support more than one checkbox on a single page, otherwise they all target the first checkbox
2023-10-08 04:22:02 +01:00
IRHM 1378193031 wip: Server settings updating through webui
- Setup the page
- Setup get/update config routes
2023-10-08 04:16:18 +01:00
IRHM 59a705222f Create user info endpoint
and use it to store user info in a writable store (instead of using localStorage). This will let us easily add more info in the future without requiring the user to login again.
2023-10-07 21:19:33 +01:00
Mr 1e50a4fa5b Merge pull request #142 from sbondCo/admin-tokens
Admin tokens
2023-10-05 23:48:54 +00:00
IRHM 2b36272c2d Finalize setupTasks and token cleanup 2023-10-06 00:41:47 +01:00
IRHM e1ead91d2c Create launch.json
autogenerated, for debugging in vscode
2023-10-06 00:31:22 +01:00
IRHM 80e7062151 useAdminToken: Fix getting token from db 2023-10-06 00:30:54 +01:00
IRHM 57d124c31e using tokens 2023-10-05 21:39:08 +01:00
IRHM 9ebfc2d18a token: request button on web ui, display in log and fix creating in db 2023-10-05 19:25:30 +01:00
IRHM a07d9e2882 wip: auth tokens
need to finish and test
2023-10-04 19:50:48 +01:00
Mr e605c8e71a Merge pull request #141 from sbondCo/admin-users
Admin users and json config
2023-10-03 20:14:35 +00:00
IRHM 1387aa3a82 JELLYFIN_HOST: Use config instead of env 2023-10-03 20:52:55 +01:00
IRHM e29f782616 Fix signup_enabled config
Use from config instead of env var and ensure default is `true`.
2023-10-03 20:47:27 +01:00
IRHM da3b932be2 runUI: extra info on each error log so we know where it errors 2023-10-02 23:29:31 +01:00
IRHM 0a649acc0f Delete .env.example 2023-10-02 23:19:17 +01:00
IRHM 4db88d040c Create setup page 2023-10-02 23:13:00 +01:00
IRHM cf6136ce92 auth/available: Return isInSetup
Web ui will be using this soon to notify user that they are creating the first admin account.
2023-10-02 22:10:41 +01:00
IRHM 1acdd245a2 Use Count instead of trying to get first user
to check if we are in setup/first user is being created
2023-10-02 22:09:20 +01:00
IRHM 1c31dd31ac logging: Define logLevel in setupLogging and return it in main for editing
so we dont have it as a global var for no reason
2023-10-02 22:02:37 +01:00
IRHM e9b75abe16 Update logging level depending on config 2023-10-02 21:59:14 +01:00
IRHM b098a25c6a Create permissions and add create_admin setup route 2023-10-02 21:25:53 +01:00
IRHM 9081d3d752 Config: Create readConfig, which runs generate if config file doesnt exist
Also make initFromConfig func (based on old ensureEnv func)
2023-09-30 09:52:46 +01:00
IRHM 383e0bf2f4 Generate barebones json config if not found
generates a JWT_SECRET
2023-09-28 23:22:49 +01:00
IRHM a600affaac v1.18.0 2023-09-25 00:50:10 +01:00
IRHM c436219b62 v1.18.0-dev1 2023-09-25 00:42:39 +01:00
Mr fd726c4cbe Merge pull request #139 from sbondCo/nav-menu-fixes
Nav menu dark theme fixes
2023-09-24 23:42:02 +00:00
IRHM 828f6f3aa3 Nav Face: Fix theming 2023-09-25 00:40:43 +01:00
IRHM 5b14857c8d Nav Icons: Fix theming 2023-09-25 00:39:28 +01:00
IRHM 82acd83395 Nav Menus: Fix button theming 2023-09-25 00:38:46 +01:00
IRHM 93ebc0380f v1.18.0-dev 2023-09-25 00:28:40 +01:00
Mr 5f4efac9ee Merge pull request #135 from sbondCo/fix-content-btns
Content: Fix play on jellyfin btn
2023-09-24 16:48:41 +00:00
IRHM efe697e1e9 Content: Fix play on jellyfin btn
Works on librewolf but not firefox esr.. not sure exactly why
2023-09-24 17:46:38 +01:00
Mr b90458ebb9 Merge pull request #134 from sbondCo/fix-pageload-noauth
Fix import page loading without auth
2023-09-23 20:05:20 +00:00
IRHM bade3f54cb Fix import pages loading without auth
Turns out the goto in import/process will still run after the first goto we run in the layout file if theres no token, so it's possible to end up on /import if you navigate to /import/process first.

added quick fix
2023-09-23 21:04:10 +01:00
Mr 975fddfc6b Merge pull request #133 from sbondCo/filtering
Watched List Filtering
2023-09-23 19:45:39 +00:00
IRHM 31c23ac860 Store: Save activeFilters to localstorage
I'm dumb so it has to go in activeFilterReal key
2023-09-23 20:44:54 +01:00
IRHM 7519098d47 Nav: Change filter btn stroke width on hover 2023-09-23 15:25:10 +01:00
IRHM 4745d528f2 Nav: Manually space btns 2023-09-23 15:23:11 +01:00
IRHM a4d0acb5dd Filters: Manually add each button
so we can change 'hold' text to 'held' without complicating the code
2023-09-23 15:07:35 +01:00
IRHM 10bd2e4bd3 Nav: Add menu class to face menu div 2023-09-23 15:03:37 +01:00
IRHM a0f6991746 Filters: Get basic filtering working 2023-09-23 14:49:39 +01:00
IRHM 61cdf91382 Nav: basic filter menu 2023-09-23 00:49:13 +01:00
IRHM 7f15f796f1 v1.17.0 2023-09-19 21:59:26 +01:00
IRHM 31ace217be v1.17.0-dev2 2023-09-19 20:47:56 +01:00
IRHM c502950473 import/process: Scroll to top when start import clicked 2023-09-19 20:46:57 +01:00
IRHM 60e0652123 Modal: Make backdrop fixed 2023-09-19 20:42:47 +01:00
IRHM 53f2ae1bdb v1.17.0-dev1 2023-09-18 22:45:36 +01:00
IRHM 118c6dfba4 Modal: Set position to fixed 2023-09-18 22:44:52 +01:00
IRHM 4d789d46c6 v1.17.0-dev 2023-09-18 22:28:57 +01:00
Mr f461f56299 Merge pull request #132 from sbondCo/import
Importing text watch list
2023-09-18 00:01:59 +00:00
IRHM b16e18fe13 import: Show one file at a time and only text files errors 2023-09-18 00:59:49 +01:00
IRHM 9055bdd58a import: Add beta text to header 2023-09-18 00:50:40 +01:00
IRHM e1e900d7f8 import/process: Disable inputs whilst importing 2023-09-17 17:37:19 +01:00
IRHM 617be082ac import/some-failed: Add some details to what the page is for 2023-09-17 17:23:13 +01:00
IRHM 6738963141 Remove some unused imports 2023-09-17 17:17:02 +01:00
IRHM 64c1f7fc11 Search: Bind search bar value to state
so we can manipulate it.

- When navigating to a search url without searching yourself, we can add the query to the search bar.

- When search page destroys (is navigated away from), we can remove the query from search bar.
2023-09-17 17:16:00 +01:00
IRHM 147561f8c7 import: Create some-failed page for manual processing
So user knows what fails and can attempt to fix it themselves by searching
2023-09-17 16:59:02 +01:00
IRHM 8a5cc8bdef import/process: Remove items that when trimmed are emtpy 2023-09-16 16:26:45 +01:00
IRHM 6fafdf5dc2 import/process: Remove todo note, already done 2023-09-16 15:53:48 +01:00
IRHM a9e871a393 import: Add spinner whilst reading file 2023-09-16 15:53:28 +01:00
IRHM b10c17d485 Fix input colors, trim imported content names 2023-09-16 15:47:54 +01:00
IRHM ccfd5a8fcb v1.16.1 2023-09-15 22:47:55 +01:00
IRHM f6ef3f9fa1 v1.16.1-dev 2023-09-15 22:39:18 +01:00
Mr 690ae67f2a Merge pull request #126 from sbondCo/sticknavdamnitstiiickckckckck
Make Nav Sticky
2023-09-15 21:31:53 +00:00
IRHM d6666a8072 Nav: close submenus when scrolling down 2023-09-15 22:28:31 +01:00
IRHM 9d8140a137 fix nav bg 2023-09-15 22:26:20 +01:00
Mr a701528f7f Merge pull request #131 from stavros-k/patch-1
Do not fail on `.env` file missing
2023-09-15 20:57:47 +00:00
Stavros Kois 87fec68591 Do not fail on .env file missing
In setups where you set the env vars directly, eg Docker or Kubernetes, there is no need to require a .env file
2023-09-12 00:33:07 +03:00
IRHM d425dda240 import/process: Show cancelled importing message 2023-09-09 00:23:04 +01:00
IRHM 12f33bfb3f import/process: Cancel importing on destroy 2023-09-09 00:13:06 +01:00
IRHM cc1df09cb8 import/process: Add padding at bottom of page 2023-09-09 00:02:14 +01:00
IRHM 5f1ef5145a Modal: Make overlay always black
Dont want to flashbang our loyal customers when they open it up
2023-09-08 23:37:05 +01:00
IRHM 405c8169cf import/process: Fix table in dark mode
and add IMPORt_FAILED statement
2023-09-08 23:34:54 +01:00
IRHM 9b2259a9a3 Watched: Add activity as imported when imported 2023-09-08 23:01:24 +01:00
IRHM 9b8fa436b8 Disabled button styling 2023-09-08 23:00:54 +01:00
IRHM c28a1e3793 import: Add item to watched list 2023-09-08 22:43:46 +01:00
IRHM 90dfd39f91 Content: Make content detail request funcs get appentoresponse as param 2023-09-07 18:51:53 +01:00
IRHM 78d14c49b4 import: Add some logging 2023-09-07 18:21:20 +01:00
IRHM 2923337258 import: Filter out people on server 2023-09-07 18:17:42 +01:00
IRHM 7ad6621fbb WatchedList: Add import button on empty watched list 2023-09-07 17:49:56 +01:00
Mr bfab3b2802 Merge pull request #128 from sbondCo/readme-demo-badge-url
README: Update demo badge url
2023-09-06 10:46:40 +00:00
Mr 9283b818f1 README: Update demo badge url
Was still pointing to old domain.
2023-09-06 00:39:43 +00:00
IRHM cc00896fa4 import/process: Add remove row button 2023-09-05 23:05:20 +01:00
IRHM a61f305f03 import/process: Skip empty names and handle NOT_FOUND 2023-09-05 22:29:09 +01:00
IRHM c4b6e41016 Import: Make browse button take up 100% width 2023-09-05 22:28:54 +01:00
IRHM 0e4223787f DropDown: Transparent background in blendIn mode and fix list zindex 2023-09-05 22:18:43 +01:00
IRHM d9abc061ed Modal: Add close button
use it in import/process
2023-09-05 22:14:56 +01:00
IRHM 8f4ea45428 import/process: multi modal: Use year to show item first with that year 2023-09-05 21:45:56 +01:00
IRHM 6366377e9e DropDown: Create blendIn option
and use it for import table type
2023-09-04 17:52:52 +01:00
IRHM 669bfa026d import/process: Allow editing type 2023-09-04 17:46:26 +01:00
IRHM 15c6d902f2 Create DropDown component 2023-09-04 17:44:33 +01:00
IRHM 80f119ca81 import/process: Fix table headers padding 2023-09-04 16:55:19 +01:00
IRHM c287e2fb76 import/process: Create back button 2023-09-04 16:53:34 +01:00
IRHM 4c597508b3 import/process: Show year and type after import 2023-09-04 16:47:21 +01:00
IRHM 42fff9c3cb import: Use equalfold 2023-09-03 20:51:34 +01:00
IRHM c0d6bc0f4d import: Improve multiple results found processing
If we get multiple results, but there is only one result with the name matching 100% then use that as the successful imported item
2023-09-03 20:34:11 +01:00
IRHM 98a5818498 Modal: Use title and desc props 2023-09-03 20:09:30 +01:00
IRHM d228da76c1 import/process: Flow of importing 2023-09-03 19:36:57 +01:00
IRHM 75073e48ad import/process: Show multiple results for imported item 2023-09-03 19:13:32 +01:00
IRHM 3702fa5e36 WIP: import: Setup barebones ep and request to it 2023-09-02 18:50:18 +01:00
IRHM 7bb56331ee import/process: Type number on year inputs 2023-09-02 16:40:45 +01:00
IRHM b953a41485 import/process: Style import button 2023-09-02 15:41:35 +01:00
IRHM a3ead1ca7e import/process: Basic data editing and adding rows 2023-09-02 15:36:59 +01:00
IRHM aeb49bd587 import: Support drag and drop 2023-09-01 20:20:10 +01:00
IRHM c4ac52267b import/process: Return to /import if no list 2023-09-01 20:19:53 +01:00
IRHM b61d8459e0 import/process: Create table and display data from text file 2023-08-31 23:29:08 +01:00
IRHM a12c23a866 import: Read file and basic flow setup with process page 2023-08-31 19:27:26 +01:00
IRHM ebb43b67c3 Setup basic import page 2023-08-31 17:31:09 +01:00
IRHM 080a1f503e Nav: Decent hiding/showing on scroll down/up 2023-08-28 01:14:22 +01:00
IRHM 0b46205f52 Style nav for basic stickyness support 2023-08-28 00:51:48 +01:00
IRHM 79df259714 v1.16.0 2023-08-27 12:48:04 +01:00
IRHM 7b70889118 v1.16.0-dev3 2023-08-27 12:39:57 +01:00
IRHM f5135e0825 Wrap UsersList and fix dark theme accent hover color 2023-08-27 12:38:57 +01:00
IRHM 3fa9dbd109 v1.16.0-dev2 2023-08-27 01:23:38 +01:00
IRHM b2438c735b Poster: Fix dev poster watched color 2023-08-27 01:23:09 +01:00
IRHM f35c42bce5 v1.16.0-dev1 2023-08-27 01:18:39 +01:00
IRHM fd68d7d975 Nav ShareLink: Use origin instead of host, so protocol is included 2023-08-27 01:18:14 +01:00
IRHM 00e45aa6d0 v1.16.0-dev 2023-08-27 01:08:16 +01:00
Mr ce9c92010f Merge pull request #125 from sbondCo/socialize
Socialize - Allow viewing other watchlists
2023-08-27 00:01:18 +00:00
IRHM 0caa18e587 Poster: Fix interactionDisabled btn colors 2023-08-27 01:00:16 +01:00
IRHM db43c313b8 Nav: Don't show share list button if profile is private 2023-08-27 00:53:45 +01:00
IRHM 328f035d1e Nav: Add share list button
Copies your public watchlist link to clipboard
2023-08-27 00:50:31 +01:00
IRHM f1db1ff2b4 Checkbox: Add small delay to reenable
So on fast networks it doesnt look ugly
2023-08-26 23:56:48 +01:00
IRHM 2c6fa24d65 UsersList: Fix long names 2023-08-26 23:46:48 +01:00
IRHM f3b4b7acc4 Profile: Fix long names overflow 2023-08-26 23:39:44 +01:00
IRHM bed4a922c5 Profile: Disable private checkbox whilst request is running 2023-08-26 23:29:49 +01:00
IRHM fc64a66f47 CheckBox: Allow being disabled 2023-08-26 23:29:33 +01:00
IRHM 083381d4d5 Poster: Show 'unrated' text when no rating and remove button style when interactionDisabled 2023-08-26 23:19:14 +01:00
IRHM b4f3bf906b Don't show current user in search results 2023-08-26 23:05:53 +01:00
IRHM 12736f91c9 Show username on others lists 2023-08-26 22:18:07 +01:00
IRHM e1e0ad6db6 Allow viewing other users lists
Moved watchlist page to a component for reuse
2023-08-26 22:12:02 +01:00
IRHM f403e228f6 Search: Display found users 2023-08-26 19:19:22 +01:00
IRHM 242656e2f4 Create user search endpoint 2023-08-26 18:48:34 +01:00
IRHM 62ffee4d84 Create private user setting
.. and user settings support
2023-08-26 18:11:34 +01:00
IRHM b83d567306 Profile: Add private setting 2023-08-26 14:32:11 +01:00
IRHM 9d67db2466 Create Checkbox component 2023-08-26 14:31:49 +01:00
IRHM fb8dec81f1 v1.15.0 2023-08-26 13:30:15 +01:00
IRHM 115ee57687 v1.15.0-dev1 2023-08-26 02:26:26 +01:00
Mr 13c4aff1d3 Merge pull request #123 from sbondCo/fix-content-year
Content: Omit ReleaseDate if not set
2023-08-26 01:25:55 +00:00
IRHM 320a88f1db Content: Omit ReleaseDate if not set 2023-08-26 02:24:27 +01:00
IRHM f175b74320 v1.15.0-dev 2023-08-26 01:43:38 +01:00
Mr 941e8879fd Merge pull request #122 from sbondCo/loading-tooltips
Loading Notifications
2023-08-26 00:37:21 +00:00
IRHM a739862ff1 notify: update type comment 2023-08-26 01:32:59 +01:00
IRHM c240de748b Loading notifs 2023-08-26 01:31:18 +01:00
Mr 80d343e12f Merge pull request #120 from sbondCo/fix-personposter-flash
PersonPoster: Fix white flash on hover
2023-08-24 20:31:07 +00:00
IRHM 3247860cce PersonPoster: Fix white flash on hover
.. by fixing the theme
2023-08-24 21:27:40 +01:00
Mr 67c9edb63d Merge pull request #119 from sbondCo/external-request-caching
TMDB Request Caching
2023-08-24 20:22:01 +00:00
Mr e025d5fbab Merge pull request #118 from sbondCo/imgbot
[ImgBot] Optimize images
2023-08-24 20:07:21 +00:00
Mr fdfda036d4 README: Update dev demo link 2023-08-24 20:03:52 +00:00
ImgBotApp 329fc33c8a [ImgBot] Optimize images
*Total -- 1,629.86kb -> 1,574.90kb (3.37%)

/static/favicon.png -- 17.11kb -> 12.54kb (26.7%)
/screenshot/show-details-page.png -- 303.20kb -> 285.72kb (5.77%)
/static/logo-col.png -- 32.55kb -> 31.38kb (3.57%)
/screenshot/homepage-poster-hover.png -- 431.68kb -> 419.01kb (2.94%)
/screenshot/homepage-poster-change-status.png -- 420.73kb -> 409.36kb (2.7%)
/screenshot/homepage.png -- 424.59kb -> 416.89kb (1.81%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2023-08-22 22:13:05 +00:00
IRHM 9b8b59df5e Add cachePage to rest of content routes 2023-08-22 23:02:20 +01:00
IRHM 5d0bbd9825 wip: Use gorm-contrib/cache middleware
Used on query route for now to test
2023-08-22 22:10:27 +01:00
Mr 4a910c9c57 Merge pull request #111 from kurtmgray/add-year
add year to Poster
2023-08-22 19:51:54 +00:00
IRHM b7ce96c576 Format Poster and Watched page 2023-08-22 18:16:52 +01:00
Kurt Gray 34b756d039 remove data/ 2023-08-22 07:55:50 -07:00
Kurt Gray 3aec67865a Merge branch 'sbondCo:dev' into add-year 2023-08-21 21:29:41 -07:00
Kurt Gray a051a07b1a made requested changes 2023-08-21 21:27:20 -07:00
Kurt Gray 2da1af2f12 requested changes 2023-08-21 21:23:09 -07:00
Kurt Gray bf0b93f90f -dateStr
Co-authored-by: Mr <37304121+IRHM@users.noreply.github.com>
2023-08-21 18:43:27 -07:00
Kurt Gray 76ebdbfe8b conditional on time element
Co-authored-by: Mr <37304121+IRHM@users.noreply.github.com>
2023-08-21 18:42:46 -07:00
IRHM cc94be7d9b v1.14.0 2023-08-21 23:39:37 +01:00
IRHM 11e575faf1 v1.14.0-dev1 2023-08-21 23:00:20 +01:00
IRHM 658c4ba114 Make backdrop mask-image darker 2023-08-21 22:59:52 +01:00
IRHM a62a05790f v1.14.0-dev 2023-08-21 22:32:34 +01:00
Mr 2dbbd08ea3 Merge pull request #116 from sbondCo/byok
Support using custom TMDB_KEY
2023-08-21 21:24:40 +00:00
IRHM 8d4297211a Support using custom TMDB_KEY 2023-08-21 22:22:23 +01:00
Mr 7d8bc81aa9 Merge pull request #115 from sbondCo/slist
SeasonsList: Stop requesting season 1 twice
2023-08-21 21:08:03 +00:00
IRHM 780f922c22 SeasonsList: Stop requesting season 1 twice 2023-08-21 22:07:03 +01:00
Kurt Gray e1420ed4c4 add year to Poster 2023-08-20 16:16:11 -07:00
Mr 89849a0957 Merge pull request #110 from sbondCo/more-sorting-opts
New sorting options: Rating, Last Changed
2023-08-20 20:33:25 +00:00
IRHM 19547861ab Update watched item updatedAt time after update request 2023-08-20 21:28:01 +01:00
IRHM 05739dc085 Add LASTCHANGED and RATING sorting options 2023-08-20 21:05:03 +01:00
Mr d17f24fab9 Merge pull request #109 from sbondCo/discover
Discover
2023-08-20 19:24:12 +00:00
IRHM 075eb01533 Discover: Make sure only movies and tv are shown in trending 2023-08-20 20:23:06 +01:00
IRHM 07f236385f Nav: Fix icon shift when sort menu opened 2023-08-20 20:06:48 +01:00
IRHM 1359279e8c Use small-caps on trending titles 2023-08-20 20:02:35 +01:00
IRHM 577c90402b Discover: Add all trending, upcoming movies and shows 2023-08-20 19:49:25 +01:00
IRHM 9c9436b420 Create working discover page
Reworked Poster, instead of callbacks, handled by the page, Poster just handles the actions itself.

PosterList now supports vertical lists.

Poster can now be `small`
2023-08-20 01:14:29 +01:00
Mr c9a7ec5f66 Merge pull request #106 from sbondCo/backdrop-fade
Fade out content backdrop
2023-08-19 15:20:37 +00:00
IRHM 54c0aca002 Fade out content backdrop
looks nicer i think
2023-08-19 16:20:02 +01:00
Mr 589bd3f86d Merge pull request #105 from sbondCo/empty-list-msg
Show a better empty watched list message
2023-08-19 14:36:39 +00:00
IRHM ffbad648d8 Show a better empty watched list message 2023-08-19 15:34:48 +01:00
Mr c187c7e09d Merge pull request #104 from sbondCo/disable-signup
Support disabling signup
2023-08-19 00:44:38 +00:00
IRHM e57b45e0b8 add signup_enabled env var to examples 2023-08-19 01:43:17 +01:00
IRHM 94c12ac428 Support disabling sign up 2023-08-19 01:19:06 +01:00
IRHM a65604e315 v1.13.0 2023-08-18 22:13:45 +01:00
IRHM 73f6bd2dd5 v1.13.0-dev1 2023-08-18 22:04:52 +01:00
IRHM 993bdeb5f1 ProviderIcon: Fix disney+ logo 2023-08-18 22:03:27 +01:00
IRHM c06609cc2e Dockerfile: Use golang 1.21 2023-08-18 21:37:26 +01:00
IRHM fe2751d9a4 v1.13.0-dev 2023-08-18 21:32:22 +01:00
Mr 873833a381 Merge pull request #101 from sbondCo/jellyfin-provider
Play In Jellyfin / Providers
2023-08-18 20:31:23 +00:00
IRHM 98f5dc5394 auth: force logout old tokens
Changes in this branch require a new login, this is the easiest way to force logout of old tokens................ i think
2023-08-18 21:30:57 +01:00
IRHM 82ec4aa0e7 Only request jf content if user is jellyfin type 2023-08-18 21:16:40 +01:00
IRHM 7ab1d4b7a1 auth: fix jellyfin failed to create user error 2023-08-18 20:43:55 +01:00
IRHM 0f29aeb1d0 content: play on jellyfin button 2023-08-18 17:01:44 +01:00
IRHM c295cc490a New jellyfin endpoint for checking if content exists
Quick test on dev server proves working, needs UI then test on real server
2023-08-18 16:14:23 +01:00
IRHM bdf9b1d3d0 More provider icons 2023-08-18 02:39:30 +01:00
IRHM db6304e5f9 Make ProvidersList and ProviderIcon components
moved out and separated providers code
2023-08-18 01:15:48 +01:00
IRHM a61f0dbbf3 Filter watch providers to a specific country on server
before sending to client
2023-08-18 00:17:29 +01:00
IRHM 84cd421368 Format content pages 2023-08-17 02:28:58 +01:00
Mr 9c32a73311 Merge pull request #97 from DNYLA/dev
Streaming Providers shown for content
2023-08-17 01:20:00 +00:00
Dan cf90b8537b Merge branch 'sbondCo:dev' into dev 2023-08-17 02:15:57 +01:00
Mr 43da5706ed Merge pull request #95 from sbondCo/logging
Server Logging To File
2023-08-17 00:40:58 +00:00
IRHM 2e301c33a4 SeasonsList: Only show TBD for actual seasons
...not specials
2023-08-17 01:40:35 +01:00
IRHM 9dddac9820 Add missing env vars to readme and env example file 2023-08-17 01:34:12 +01:00
IRHM e9a17a0f4a Change lumberjack options
maxSize to 1mb, compress to false
2023-08-17 01:30:29 +01:00
IRHM 4482da9ec7 Make gin log to file 2023-08-17 01:09:17 +01:00
IRHM 0753346954 Update all logs to use slog 2023-08-17 00:59:26 +01:00
IRHM 4878e8d32b Start using slog for logging and lumberjack for writing to rotating file 2023-08-16 23:57:02 +01:00
Mr 86aef4fdd2 Merge pull request #92 from sbondCo/runtimes
Hide tv runtimes if not provided in episode_run_time array
2023-08-16 19:30:20 +00:00
IRHM 35dcaf11a1 Hide tv runtimes if not provided in episode_run_time array 2023-08-16 20:28:36 +01:00
DNYLA 2a69951d2e Added Import 2023-08-16 13:59:41 +01:00
DNYLA 67d17c5a82 Streaming Providers display 2023-08-16 13:52:27 +01:00
Mr bbd5533ac8 Merge pull request #90 from sbondCo/tbd
Show `TBD` if seasons not aired and no episodes message
2023-08-15 23:12:04 +00:00
IRHM e234524532 Show TBD if seasons not aired and no episodes message 2023-08-16 00:10:34 +01:00
Mr c4ec47ec97 Merge pull request #88 from sbondCo/deps
Update Client And Server Dependencies
2023-08-15 22:39:18 +00:00
IRHM 86b0f1a917 ugliggy lint 2023-08-15 23:35:41 +01:00
IRHM c929911187 Poster: enter on inner element go to content 2023-08-15 23:34:53 +01:00
IRHM 736a06d362 #87 Close VideoEmbedModal with escape key
Also fixed modal div linting issue
2023-08-15 23:23:18 +01:00
IRHM 755c4ca15f Fix linting issues - only extend svelte/recommended
ignore config.js files
2023-08-15 23:03:50 +01:00
IRHM a0fbb0ffef fix eslint and run format 2023-08-15 22:54:56 +01:00
IRHM e356ad604e Update server deps 2023-08-15 20:51:43 +01:00
IRHM 1c6df50622 Move to svelte 4
Update all related deps
2023-08-15 20:41:40 +01:00
IRHM 88796292be update most deps 2023-08-15 20:32:20 +01:00
Mr b97ea7076d Add contributing link and getting help section to README.md 2023-08-14 00:53:57 +00:00
Mr ee2e573083 Update CONTRIBUTING.md 2023-08-14 00:49:52 +00:00
118 changed files with 23078 additions and 4294 deletions
+2
View File
@@ -7,6 +7,8 @@ node_modules
.env.*
!.env.example
/doc
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
+25 -18
View File
@@ -1,20 +1,27 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
plugins: ['svelte3', '@typescript-eslint'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: false
}
root: true,
parser: "@typescript-eslint/parser",
extends: ["plugin:svelte/recommended"],
plugins: ["@typescript-eslint"],
ignorePatterns: ["*.cjs", "*.config.js"],
overrides: [
{
files: ["*.svelte"],
parser: "svelte-eslint-parser",
parserOptions: {
parser: "@typescript-eslint/parser"
}
}
],
parserOptions: {
project: "./tsconfig.json",
extraFileExtensions: [".svelte"],
sourceType: "module",
ecmaVersion: 2020
},
env: {
browser: true,
es2017: true,
node: false
}
};
+27
View File
@@ -0,0 +1,27 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
# Web UI
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ui"
# Server
- package-ecosystem: "gomod"
directory: "/server"
schedule:
interval: "weekly"
commit-message:
prefix: "server"
# Workflows
- package-ecosystem: "github-actions"
directory: "/" # / will look in /.github/workflows directory by default
schedule:
interval: "weekly"
commit-message:
prefix: "workflow"
+3 -3
View File
@@ -16,17 +16,17 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
+4 -4
View File
@@ -19,10 +19,10 @@ jobs:
steps:
- name: Checkout Repo
uses: actions/checkout@v3
uses: actions/checkout@v4
- name: Log in to the Container registry
uses: docker/login-action@v2
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -30,12 +30,12 @@ jobs:
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v4
uses: docker/metadata-action@v5
with:
images: ${{ env.image }}
- name: Build and push Docker image
uses: docker/build-push-action@v4
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
+50
View File
@@ -0,0 +1,50 @@
name: Publish Docs
on:
push:
branches:
- dev
paths:
- ".github/workflows/publish-docs.yml"
- "doc/**"
permissions:
contents: write
jobs:
deploy:
name: Deploy to GitHub Pages
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
defaults:
run:
working-directory: doc
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Build website
run: npm run build
# 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
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
# Build output to publish to the `gh-pages` branch:
publish_dir: ./doc/build
cname: watcharr.app
# The following lines assign commit authorship to the official
# GH-Actions bot for deploys to `gh-pages` branch:
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
# The GH actions bot is used by default if you didn't specify the two fields.
# You can swap them out with your own user credentials.
user_name: github-actions[bot]
user_email: 41898282+github-actions[bot]@users.noreply.github.com
+28
View File
@@ -0,0 +1,28 @@
name: Test doc deployment
on:
pull_request:
branches:
- dev
paths:
- ".github/workflows/test-docs.yml"
- "doc/**"
jobs:
test-deploy:
name: Test deployment
runs-on: ubuntu-latest
defaults:
run:
working-directory: doc
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
with:
node-version: 18
cache: npm
- name: Install dependencies
run: npm ci
- name: Test build website
run: npm run build
+17 -22
View File
@@ -1,23 +1,18 @@
{
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"printWidth": 100,
"useTabs": false,
"trailingComma": "none",
"arrowParens": "always",
"plugins": [
"prettier-plugin-svelte"
],
"pluginSearchDirs": [
"."
],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
"tabWidth": 2,
"semi": true,
"singleQuote": false,
"printWidth": 100,
"useTabs": false,
"trailingComma": "none",
"arrowParens": "always",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+15
View File
@@ -0,0 +1,15 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${fileDirname}"
}
]
}
+8 -2
View File
@@ -1,5 +1,11 @@
# Contributing
# Contributing 🎉
Feel free to contribute in any way: bug reports, ideas, etc - everything is welcome!
First of all, thank you for looking into contributing! 🫡
Feel free to contribute in any way: 🐞 bug reports, 💡 ideas, 🥖 new features etc - everything is welcome!
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
## Project Board
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
+1 -1
View File
@@ -1,5 +1,5 @@
# Backend
FROM golang:1.20 AS server
FROM golang:1.21 AS server
WORKDIR /server
COPY server/*.go server/go.* ./
+10 -16
View File
@@ -3,7 +3,7 @@
<p align="center">
<a href="https://github.com/sbondCo/Watcharr/pkgs/container/watcharr"><img src="https://img.shields.io/github/v/release/sbondCo/Watcharr?label=version&style=for-the-badge" /></a>
<a href="https://watcharr.lab.sbond.co"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fwatcharr.lab.sbond.co%2F" /></a>
<a href="https://beta.watcharr.app"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fbeta.watcharr.app" /></a>
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
</p>
@@ -12,7 +12,7 @@ I'm your new easily self-hosted content watched list. The place you store your w
I am built with Go and Svelte(Kit).
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://watcharr.lab.sbond.co/](https://watcharr.lab.sbond.co/)
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
@@ -20,6 +20,8 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
- [Screenshots](#screenshots)
- [Setup](#set-up)
- [Contributing](CONTRIBUTING.md)
- [Getting Help](#getting-help)
# Screenshots
@@ -37,6 +39,8 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
Currently only a [docker image](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr) is provided (**v1.9.1 and newer no longer require the `watcharr-ui` package, it is bundled inside the main `watcharr` package**).
**Note:** When first running, make sure only you have access. The first user created will become admin.
Here is a simple setup you can use yourself, using docker compose:
**docker-compose.yml**
@@ -53,24 +57,14 @@ services:
ports:
- 3080:3080
volumes:
# .env file used to configure watcharr
- .env:/.env
# Contains all of watcharr data (database & cache)
- ./data:/data
```
**.env**
After loading up Watcharr, you will be greeted with a menu to setup the first account (which will be admin).
```
# Used to sign JWT tokens. Make sure to make
# it strong, just like a very long, complicated password.
JWT_SECRET=MAKE_ME_RANDOM_AND_LONG
When logged in you can then configure the server settings from the nav face menu > settings option.
# Optional: Point to your Jellyfin install
# to enable it as an auth provider.
JELLYFIN_HOST=https://my.jellyfin.example
# Getting Help
# Optional: When not set we assume production, should only
# be set to DEV when developing the app.
MODE=prod
```
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!
+20
View File
@@ -0,0 +1,20 @@
# Dependencies
/node_modules
# Production
/build
# Generated files
.docusaurus
.cache-loader
# Misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
+41
View File
@@ -0,0 +1,41 @@
# Website
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
### Installation
```
$ yarn
```
### Local Development
```
$ yarn start
```
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
### Build
```
$ yarn build
```
This command generates static content into the `build` directory and can be served using any static contents hosting service.
### Deployment
Using SSH:
```
$ USE_SSH=true yarn deploy
```
Not using SSH:
```
$ GIT_USER=<Your GitHub username> yarn deploy
```
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
+3
View File
@@ -0,0 +1,3 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};
+8
View File
@@ -0,0 +1,8 @@
{
"label": "Installation",
"position": 2,
"link": {
"type": "generated-index",
"description": "Choose how you would like to install Watcharr."
}
}
+68
View File
@@ -0,0 +1,68 @@
---
sidebar_position: 1
---
# Docker Compose
## Installing
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
# to use an actual version, then when updating check the releases for changelogs.
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
ports:
- 3080:3080
volumes:
# Contains all of watcharr data (database & cache)
- ./data:/data
```
:::danger first account
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
:::
You can now start `Watcharr` like so:
```bash
docker compose up -d
```
If you didn't change the ports in the example, the server will be available at [http://localhost:3080/](http://localhost:3080/).
## Updating
:::danger Take care
We try taking care as to not release breaking changes, however it is still recommended that
you lookover changelogs before updating!
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
:::
Updating your server can be done in two steps:
1. Update the `image` version in your `docker-compose.yml` file.
Skip this step if you are using the `latest` tag.
```yaml
# eg. update v1.19.0 to v1.20.0 (or whatever version you are updating to)
image: ghcr.io/sbondco/watcharr:v1.19.0
```
2. Pull the new changes and re-create your container:
```bash
docker compose pull && docker compose down && docker compose up -d
```
And that is it!
+74
View File
@@ -0,0 +1,74 @@
---
sidebar_position: 10
---
# From Source
## Installing
1. Get the code
```bash
git clone https://github.com/sbondCo/Watcharr.git && \
cd Watcharr
```
If you want a specific version, checkout a version tag (replace `<tag>` with the tag):
```bash
git checkout <tag>
```
2. Build the frontend
The server will use the built files, so we move them to the `ui` folder, besides where it's binary will be built.
```bash
npm i && \
npm run build && \
mv ./build ./server/ui
```
3. Build the server
```bash
cd server && \
go mod download && \
GOOS=linux go build -o ./watcharr
```
4. Run the server
:::danger first account
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
:::
```bash
./watcharr
```
5. Visit [http://localhost:3080/](http://localhost:3080/) and setup Watcharr.
## Updating
:::danger Take care
We try taking care as to not release breaking changes, however it is still recommended that
you lookover changelogs before updating!
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
:::
Updating is the same as installing except:
1. Cleanup old build. Delete the `ui` folder and the `watcharr` binary.
2. Replace step one with getting the new code:
```bash
git pull
```
If you checked out a tag, update to the new tag as well.
+10
View File
@@ -0,0 +1,10 @@
---
sidebar_position: 1
---
# Introduction
Watcharr is for the peeps who like to keep a list of all the content they have ever watched.
If you would like more insight, the [adding to your watchlist guide](/docs/usage/adding-to-watchlist)
is a great example of how Watcharr is used.
+8
View File
@@ -0,0 +1,8 @@
{
"label": "Usage",
"position": 3,
"link": {
"type": "generated-index",
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
}
}
+39
View File
@@ -0,0 +1,39 @@
---
sidebar_position: 1
---
# Adding To Your Watchlist
Adding movies or shows to your watchlist is the core feature of Watcharr.
The homepage is where your list will appear once you have added some.
To get started, you can follow along with these steps to add your first movie/show:
1. Search for the content:
![Searching For Content](./img/search-for-content.png)
2. Hover or click on the content you want to add:
![Hovering over poster](./img/search-for-content-hover-poster.png)
3. Clicking the + (Plus) icon will let you give the content a status.
Currently supported statuses include: `Finished`, `Watching`, `Held`, `Planned`, `Dropped`.
![Giving content a status](./img/search-for-content-hover-poster-status.png)
4. Rate the content.
![Giving content a rating](./img/search-for-content-hover-poster-rate.png)
If you prefer, you can also do this by clicking/tapping on the active poster again.
This will take you to the contents full page. Here you can see all the options, including
adding `Thoughts` for the content.
![Content full page](./img/content-full-page.png)
`Thoughts` can be anything you would like to write down or remember about the specific movie/show.
For example, sometimes I like to write something specific that I _really_ liked about the content,
or for some shows, I write down my favourite seasons and how many times I have watched them.
Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

+101
View File
@@ -0,0 +1,101 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
const lightCodeTheme = require("prism-react-renderer/themes/github");
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
/** @type {import('@docusaurus/types').Config} */
const config = {
title: "Watcharr Docs",
tagline:
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
favicon: "img/favicon.png",
// Set the production url of your site here
url: "https://watcharr.app",
// Set the /<baseUrl>/ pathname under which your site is served
// For GitHub pages deployment, it is often '/<projectName>/'
baseUrl: "/",
// GitHub pages deployment config.
// If you aren't using GitHub pages, you don't need these.
organizationName: "sbondCo", // Usually your GitHub org/user name.
projectName: "Watcharr", // Usually your repo name.
onBrokenLinks: "throw",
onBrokenMarkdownLinks: "warn",
trailingSlash: false,
// Even if you don't use internalization, you can use this field to set useful
// metadata like html lang. For example, if your site is Chinese, you may want
// to replace "en" with "zh-Hans".
i18n: {
defaultLocale: "en",
locales: ["en"]
},
presets: [
[
"classic",
/** @type {import('@docusaurus/preset-classic').Options} */
({
docs: {
sidebarPath: require.resolve("./sidebars.js"),
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
},
blog: {
showReadingTime: true,
// Please change this to your repo.
// Remove this to remove the "edit this page" links.
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
},
theme: {
customCss: require.resolve("./src/css/custom.css")
}
})
]
],
themeConfig:
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
({
image: "img/social-card.png",
navbar: {
title: "Watcharr",
logo: {
alt: "Watcharr Logo",
src: "img/favicon.png"
},
items: [
{
type: "docSidebar",
sidebarId: "tutorialSidebar",
position: "left",
label: "Docs"
},
{
type: "docsVersionDropdown"
},
{
href: "https://github.com/sbondCo/Watcharr",
label: "GitHub",
position: "right"
}
]
},
prism: {
theme: lightCodeTheme
// darkTheme: darkCodeTheme
},
colorMode: {
defaultMode: "light",
// Dark theme currently disabled.. no time to fix the icons etc.
disableSwitch: true
}
})
};
module.exports = config;
+12791
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "watcharr-doc",
"version": "0.0.0",
"private": true,
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
"serve": "docusaurus serve",
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids"
},
"dependencies": {
"@docusaurus/core": "2.4.3",
"@docusaurus/preset-classic": "2.4.3",
"@mdx-js/react": "^1.6.22",
"clsx": "^1.2.1",
"prism-react-renderer": "^1.3.5",
"react": "^17.0.2",
"react-dom": "^17.0.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.4.3"
},
"browserslist": {
"production": [
">0.5%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"engines": {
"node": ">=16.14"
}
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
// By default, Docusaurus generates a sidebar from the docs folder structure
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
{
type: 'category',
label: 'Tutorial',
items: ['tutorial-basics/create-a-document'],
},
],
*/
};
module.exports = sidebars;
@@ -0,0 +1,61 @@
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
const FeatureList = [
{
title: "Free and Open",
Svg: require("@site/static/img/icon/code-slash.svg").default,
description: (
<>
Watcharr is free and open source software distributed under the MIT license. Feel free to
browse, modify or contribute!
</>
)
},
{
title: "Simple",
Svg: require("@site/static/img/icon/happy.svg").default,
description: (
<>
Watcharr is incredibly easy to dive into. Install without hassle and get started with the
seamless and intuitive user experience.
</>
)
},
{
title: "Built With Go and Svelte",
Svg: require("@site/static/img/icon/go.svg").default,
description: (
<>Don't know what else to add here, so now you know Watcharr is built with Go and Svelte.</>
)
}
];
function Feature({ Svg, title, description }) {
return (
<div className={clsx("col col--4")}>
<div className="text--center">
<Svg className={styles.featureSvg} role="img" />
</div>
<div className="text--center padding-horiz--md">
<h3>{title}</h3>
<p>{description}</p>
</div>
</div>
);
}
export default function HomepageFeatures() {
return (
<section className={styles.features}>
<div className="container">
<div className="row">
{FeatureList.map((props, idx) => (
<Feature key={idx} {...props} />
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,11 @@
.features {
display: flex;
align-items: center;
padding: 2rem 0;
width: 100%;
}
.featureSvg {
height: 200px;
width: 200px;
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Any CSS included here will be global. The classic template
* bundles Infima by default. Infima is a CSS framework designed to
* work well for content-centric websites.
*/
/* You can override the default Infima variables here. */
:root {
--ifm-color-primary: #2e8555;
--ifm-color-primary-dark: #29784c;
--ifm-color-primary-darker: #277148;
--ifm-color-primary-darkest: #205d3b;
--ifm-color-primary-light: #33925d;
--ifm-color-primary-lighter: #359962;
--ifm-color-primary-lightest: #3cad6e;
--ifm-code-font-size: 95%;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
}
/* For readability concerns, you should choose a lighter palette in dark mode. */
[data-theme='dark'] {
--ifm-color-primary: #25c2a0;
--ifm-color-primary-dark: #21af90;
--ifm-color-primary-darker: #1fa588;
--ifm-color-primary-darkest: #1a8870;
--ifm-color-primary-light: #29d5b0;
--ifm-color-primary-lighter: #32d8b4;
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
+51
View File
@@ -0,0 +1,51 @@
import React from "react";
import Link from "@docusaurus/Link";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import Layout from "@theme/Layout";
import HomepageFeatures from "@site/src/components/HomepageFeatures";
import LogoCol from "@site/static/img/logo-col.png";
import styles from "./index.module.css";
import clsx from "clsx";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
return (
<header className="hero shadow--lw">
<div className={styles.heroContainer}>
<div className={styles.heroWrapper}>
<img src={LogoCol} />
<h1 className="hero__title">{siteConfig.title}</h1>
<p className="hero__subtitle">{siteConfig.tagline}</p>
<div className={styles.buttons}>
<Link className="button button--secondary button--lg" to="/docs/introduction">
Get Started
</Link>
<Link
className="button button--secondary button--outline button--lg"
to="https://beta.watcharr.app"
target="_blank"
>
Demo
</Link>
</div>
</div>
</div>
</header>
);
}
export default function Home() {
const { siteConfig } = useDocusaurusContext();
return (
<Layout
title={`${siteConfig.title}`}
description="Description will go into a meta tag in <head />"
>
<HomepageHeader />
<main>
<HomepageFeatures />
</main>
</Layout>
);
}
+34
View File
@@ -0,0 +1,34 @@
/**
* CSS files with the .module.css suffix will be treated as CSS modules
* and scoped locally.
*/
.heroContainer {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
}
.heroWrapper {
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
max-width: 600px;
padding-left: 2rem;
padding-right: 2rem;
text-align: center;
}
.heroWrapper img {
width: 30vh;
max-width: 250px;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
+7
View File
@@ -0,0 +1,7 @@
---
title: Markdown page example
---
# Markdown page example
You don't need React to write simple standalone pages.
View File
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M160 389a20.91 20.91 0 01-13.82-5.2l-128-112a21 21 0 010-31.6l128-112a21 21 0 0127.66 31.61L63.89 256l109.94 96.19A21 21 0 01160 389zM352 389a21 21 0 01-13.84-36.81L448.11 256l-109.94-96.19a21 21 0 0127.66-31.61l128 112a21 21 0 010 31.6l-128 112A20.89 20.89 0 01352 389zM208 437a21 21 0 01-20.12-27l96-320a21 21 0 1140.23 12l-96 320A21 21 0 01208 437z"/></svg>

After

Width:  |  Height:  |  Size: 447 B

+1
View File
@@ -0,0 +1 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Go</title><path d="M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M414.39 97.61A224 224 0 1097.61 414.39 224 224 0 10414.39 97.61zM184 208a24 24 0 11-24 24 23.94 23.94 0 0124-24zm167.67 106.17c-12 40.3-50.2 69.83-95.62 69.83s-83.62-29.53-95.72-69.83a8 8 0 017.83-10.17h175.69a8 8 0 017.82 10.17zM328 256a24 24 0 1124-24 23.94 23.94 0 01-24 24z"/></svg>

After

Width:  |  Height:  |  Size: 373 B

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

-1
View File
@@ -10,5 +10,4 @@ services:
ports:
- 3080:3080
volumes:
- ./server/.env:/.env
- ./container_data:/data
-1
View File
@@ -7,5 +7,4 @@ services:
ports:
- 3080:3080
volumes:
- ./server/.env:/.env
- ./container_data:/data
+3518 -3215
View File
File diff suppressed because it is too large Load Diff
+35 -33
View File
@@ -1,35 +1,37 @@
{
"name": "watcharr",
"version": "1.12.0",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --plugin-search-dir . --check . && eslint .",
"format": "prettier --plugin-search-dir . --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.2.4",
"@sveltejs/kit": "^1.15.2",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"eslint": "^8.38.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.7",
"prettier-plugin-svelte": "^2.10.0",
"svelte": "^3.58.0",
"svelte-check": "^3.2.0",
"svelte-preprocess": "^5.0.4",
"tslib": "^2.5.0",
"typescript": "^5.0.4",
"vite": "^4.2.1"
},
"type": "module",
"dependencies": {
"axios": "^1.3.4"
}
"name": "watcharr",
"version": "1.20.1",
"private": true,
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"server": "cd ./server && MODE=DEV go run ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^1.3.1",
"@sveltejs/kit": "^1.25.2",
"@typescript-eslint/eslint-plugin": "^6.9.0",
"@typescript-eslint/parser": "^6.9.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-svelte": "^2.34.0",
"prettier": "^3.0.3",
"prettier-plugin-svelte": "^3.0.3",
"svelte": "^4.2.2",
"svelte-check": "^3.5.2",
"svelte-eslint-parser": "^0.33.1",
"svelte-preprocess": "^5.0.4",
"tslib": "^2.6.2",
"typescript": "^5.2.2",
"vite": "^4.5.0"
},
"type": "module",
"dependencies": {
"axios": "^1.5.1"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 421 KiB

After

Width:  |  Height:  |  Size: 409 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

After

Width:  |  Height:  |  Size: 286 KiB

-7
View File
@@ -1,7 +0,0 @@
# Used to sign JWT tokens. Make sure to make
# it strong, just like a very long, complicated password.
JWT_SECRET=ME_A_DEVELOPEER
# Optional: When not set we assume production, should only
# be set to DEV when developing the app.
MODE=DEV
+5 -4
View File
@@ -2,7 +2,7 @@ package main
import (
"errors"
"fmt"
"log/slog"
"gorm.io/gorm"
)
@@ -16,6 +16,7 @@ var (
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
)
type Activity struct {
@@ -42,7 +43,7 @@ func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
activity := new([]Activity)
res := db.Model(&Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
if res.Error != nil {
println("Failed getting activity from database:", res.Error.Error())
slog.Error("Failed getting activity from database", "error", res.Error.Error())
return []Activity{}, errors.New("failed getting activity")
}
return *activity, nil
@@ -55,9 +56,9 @@ func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, err
activity := Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data}
res := db.Create(&activity)
if res.Error != nil {
println("Error adding activity to database:", res.Error.Error())
slog.Error("Error adding activity to database", "error", res.Error.Error())
return Activity{}, errors.New("failed adding new activity to database")
}
fmt.Printf("%+v\n", activity)
slog.Debug("Adding activity", "added_activity", activity)
return activity, nil
}
+210 -33
View File
@@ -10,6 +10,7 @@ import (
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/url"
"os"
@@ -29,6 +30,16 @@ var (
JELLYFIN_USER UserType = 1
)
// User Perms
// iota auto increments for us so when adding new
// perms, add to bottom as to not change other perm
// values.
const (
PERM_NONE int = 1 << iota
PERM_ADMIN
PERM_REQUEST_CONTENT
)
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
// This is incase different users with same name from different services try to signup.
type User struct {
@@ -40,7 +51,33 @@ type User struct {
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
// ID of user from the third party service, this will be used purely for lookup of user at signin.
ThirdPartyID string `json:"-"`
Watched []Watched
// Auth token from third party (jellyfin)
ThirdPartyAuth string `json:"-"`
Watched []Watched
Permissions int `gorm:"default:1" json:"-"`
// All user settings cols, in another struct for reusability
UserSettings
}
// This struct uses pointer to the values, so in update user settings,
// we can tell which setting is being updated (if not nil..).
type UserSettings struct {
// Is profile private
Private *bool `gorm:"default:false" json:"private"`
// If ui 'spoilers' should be shown
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
}
// We use a separate struct for registration to avoid confusion
// and possible accidents where we allow a user to pass in a
// property from the main User struct that shouldn't be allowed.
type UserRegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type UseAdminTokenRequest struct {
Token string `json:"token" binding:"required"`
}
type JellyfinAuth struct {
@@ -53,12 +90,19 @@ type JellyfinAuthResponse struct {
ID string `json:"Id"`
Name string `json:"Name"`
} `json:"User"`
AccessToken string `json:"AccessToken"`
}
type AuthResponse struct {
Token string `json:"token"`
}
type AvailableAuthProvidersResponse struct {
AvailableAuthProviders []string `json:"available"`
SignupEnabled bool `json:"signupEnabled"`
IsInSetup bool `json:"isInSetup"`
}
type ArgonParams struct {
memory uint32
iterations uint32
@@ -68,19 +112,21 @@ type ArgonParams struct {
}
type TokenClaims struct {
UserID uint `json:"userId"`
Username string `json:"username"`
UserID uint `json:"userId"`
Username string `json:"username"`
Type UserType `json:"type"`
jwt.RegisteredClaims
}
// Auth middleware
func AuthRequired() gin.HandlerFunc {
// If db is passed, extra user info from the database will be fetched.
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
println("AuthRequired middleware hit")
slog.Debug("AuthRequired middleware hit")
atoken := c.GetHeader("Authorization")
// Make sure auth header isn't empty
if atoken == "" {
println("Returning 401, Authorization header not provided")
slog.Warn("Returning 401, Authorization header not provided")
c.AbortWithStatus(401)
return
}
@@ -88,19 +134,72 @@ func AuthRequired() gin.HandlerFunc {
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(os.Getenv("JWT_SECRET")), nil
})
if err != nil {
slog.Error("AuthRequired failed to parse token", "error", err)
c.AbortWithStatus(401)
return
}
// If token is valid, go to next handler
if claims, ok := token.Claims.(*TokenClaims); ok && token.Valid {
println("Token valid", claims.Username)
// Check if token issuedAt is from before `timeOfNewLoginRequired`.
// Basically just so we can logout old tokens and force relogin...
// since new changes require the user login again.
timeOfNewLoginRequired, _ := time.Parse(time.RFC822, "18 Aug 23 20:30 UTC")
if claims.IssuedAt.Before(timeOfNewLoginRequired) {
slog.Info("Token is from before timeOfNewLoginRequired.. returning 401", "token_issued_at", claims.IssuedAt, "time_of_new_login_required", timeOfNewLoginRequired)
c.AbortWithStatus(401)
return
}
slog.Debug("Token is valid", "claims", claims)
c.Set("userId", claims.UserID)
c.Set("userType", claims.Type)
// If db passed, get extra user info and set as variables in req context
if db != nil {
slog.Debug("AuthRequired: db passed.. getting extra user info")
dbUser := new(User)
res := db.Where("id = ?", claims.UserID).Take(&dbUser)
if res.Error != nil {
slog.Error("AuthRequired: Failed to select user from database", "error", res.Error)
c.AbortWithStatus(401)
return
}
slog.Debug("AuthRequired: fetched extra user info. Setting vars.", "userThirdPartyId", dbUser.ThirdPartyID, "userThirdPartyAuth", "lol this is censored dude")
c.Set("userThirdPartyId", dbUser.ThirdPartyID)
c.Set("userThirdPartyAuth", dbUser.ThirdPartyAuth)
c.Set("username", dbUser.Username)
c.Set("userPermissions", dbUser.Permissions)
}
c.Next()
} else {
fmt.Println(err)
slog.Error("Token is **not** valid")
c.AbortWithStatus(401)
return
}
}
}
func register(user *User, db *gorm.DB) (AuthResponse, error) {
println("Registering", user.Username)
// Admin only middleware (use after AuthRequired with extra info!)
func AdminRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.GetUint("userId")
perms := c.GetInt("userPermissions")
if hasPermission(perms, PERM_ADMIN) {
slog.Debug("AdminRequired: User has permission to access admin only route", "user_id", userId)
c.Next()
return
}
slog.Info("AdminRequired: User denied permission to access admin only route", "user_id", userId)
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.")
return AuthResponse{}, errors.New("registering is disabled")
}
var user User = User{Username: ur.Username, Password: ur.Password}
slog.Info("A user is registering", "username", user.Username)
hash, err := hashPassword(user.Password, &ArgonParams{
memory: 64 * 1024,
iterations: 3,
@@ -115,100 +214,121 @@ func register(user *User, db *gorm.DB) (AuthResponse, error) {
// Update user obj to replace the plaintext pass with hash
user.Password = hash
// Update user permissions if an initial perm is passed in (1 is default)
if initialPerm != 0 && initialPerm != PERM_NONE {
slog.Info("User being registered has been given extra initial permissions", "initial_perm", initialPerm)
user.Permissions = initialPerm
}
res := db.Create(&user)
if res.Error != nil {
// If error is because unique contraint failed.. user already exists
if strings.Contains(res.Error.Error(), "UNIQUE") {
println("Unique contraint fail:", res.Error.Error())
slog.Error("Registration failed", "error", res.Error.Error(), "error_pretty", "User already exists")
return AuthResponse{}, errors.New("User already exists")
}
panic(err)
slog.Error("Registration failed", "error", err, "error_pretty", "Watcharr does not know why this failed, assume database operation failed")
return AuthResponse{}, errors.New("unknown error")
}
// Gorm fills our user obj with the ID from db after insert,
// just ensure it actually has.
if user.ID == 0 {
fmt.Println("user.ID not filled out after registration", user.ID)
slog.Error("user.ID not filled out after registration", "userId", user.ID)
return AuthResponse{}, errors.New("failed to get user id, try login")
}
token, err := signJWT(user)
token, err := signJWT(&user)
if err != nil {
fmt.Println("Failed to sign new jwt:", err)
slog.Error("Registration: Failed to sign new jwt", "error", err)
return AuthResponse{}, errors.New("failed to get auth token")
}
return AuthResponse{Token: token}, nil
}
func registerFirstUser(user *UserRegisterRequest, db *gorm.DB) (AuthResponse, error) {
// Ensure no users exist
var userCount int64
uresp := db.Model(&User{}).Count(&userCount)
if uresp.Error != nil {
slog.Error("registerFirstUser: User count query failed!", "error", uresp.Error)
return AuthResponse{}, errors.New("failed to query db for a count of users")
}
if userCount != 0 {
slog.Warn("registerFirstUser: registered users already exist.")
return AuthResponse{}, errors.New("first user already registered")
}
slog.Info("Registering first user.")
return register(user, PERM_ADMIN, db)
}
func login(user *User, db *gorm.DB) (AuthResponse, error) {
fmt.Println("Logging in", user.Username)
slog.Debug("A User Is Logging In", "username", user.Username)
dbUser := new(User)
res := db.Where("username = ? AND (type IS NULL OR type = 0)", user.Username).Take(&dbUser)
if res.Error != nil {
fmt.Println("Failed to select user from database for login:", res.Error)
slog.Error("Failed to select user from database for login", "error", res.Error)
return AuthResponse{}, errors.New("User does not exist")
}
match, err := compareHash(user.Password, dbUser.Password)
if err != nil {
fmt.Println("Failed to compare pass to hash for login:", err)
slog.Error("Failed to compare pass to hash for login", "error", err)
return AuthResponse{}, errors.New("failed to login")
}
if !match {
fmt.Println("User failed to provide correct password for login:", match)
slog.Error("User failed to provide correct password for login", "hash_matched", match)
return AuthResponse{}, errors.New("incorrect details")
}
token, err := signJWT(dbUser)
if err != nil {
fmt.Println("Failed to sign new jwt:", err)
slog.Error("Failed to sign new jwt", "error", err)
return AuthResponse{}, errors.New("failed to get auth token")
}
return AuthResponse{Token: token}, nil
}
func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
jellyfinHost := os.Getenv("JELLYFIN_HOST")
if jellyfinHost == "" {
println("Request made to login via Jellyfin, but JELLYFIN_HOST environment variable is not set.")
if Config.JELLYFIN_HOST == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
return AuthResponse{}, errors.New("jellyfin login not enabled")
}
base, err := url.Parse(jellyfinHost + "/Users/AuthenticateByName")
base, err := url.Parse(Config.JELLYFIN_HOST + "/Users/AuthenticateByName")
if err != nil {
println("Failed to parse AuthenticateByName api endpoint url:", err.Error())
slog.Error("Failed to parse AuthenticateByName api endpoint url", "error", err.Error())
return AuthResponse{}, errors.New("failed to parse api uri")
}
// Marshall struct as json
usrJSON, err := json.Marshal(JellyfinAuth{Username: user.Username, Pw: user.Password})
if err != nil {
println("Error marshalling JellyfinAuth JSON", err.Error())
slog.Error("Error marshalling JellyfinAuth JSON", "error", err.Error())
return AuthResponse{}, errors.New("failed to marshal json")
}
// Run auth request
// res, err := http.Post(base.String(), "application/json", bytes.NewBuffer(usrJSON))
client := &http.Client{}
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer(usrJSON))
if err != nil {
println("creating request to jellyfin for auth failed:", err)
slog.Error("Creating request to jellyfin for auth failed", "error", err)
return AuthResponse{}, errors.New("request failed")
}
req.Header.Add("Content-Type", "application/json")
req.Header.Add("X-Emby-Authorization", "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor"+user.Username+"\", Version=\"10.8.0\"")
res, err := client.Do(req)
if err != nil {
println("making request to jellyfin for auth failed:", err)
slog.Error("making request to jellyfin for auth failed", "error", err)
return AuthResponse{}, errors.New("request failed")
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
println("Error reading response", err.Error())
slog.Error("Error reading jellyfin auth response", "error", err.Error())
return AuthResponse{}, err
}
if res.StatusCode != 200 {
println("Jellyfin auth non 200 status code:", res.StatusCode, string(body))
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
return AuthResponse{}, errors.New("incorrect details")
}
// Process auth response
@@ -228,32 +348,81 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
// Record not found, so we should create the user
// dbUser will be empty, so we can just reuse it for this purpose.
dbUser.ThirdPartyID = resp.User.ID
dbUser.ThirdPartyAuth = resp.AccessToken
dbUser.Username = resp.User.Name
dbUser.Type = JELLYFIN_USER
dbRes = db.Create(&dbUser)
if dbRes.Error != nil {
println("Failed to create new user in db from jellyfin response:", err.Error())
slog.Error("Failed to create new user in db from jellyfin response", "error", dbRes.Error)
return AuthResponse{}, errors.New("failed to create new user from jellyfin")
}
} else {
return AuthResponse{}, errors.New("error locating user in db")
}
}
// If user exists.. update their access token in db
if resp.AccessToken != "" {
slog.Debug("Jellyfin user login - updating user with new access token")
dbUser.ThirdPartyAuth = resp.AccessToken
db.Save(&dbUser)
}
token, err := signJWT(dbUser)
if err != nil {
fmt.Println("Failed to sign new jwt:", err)
slog.Error("Failed to sign new (jellyfin login) jwt", "error", err)
return AuthResponse{}, errors.New("failed to get auth token")
}
return AuthResponse{Token: token}, nil
}
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
var dbToken Token
resp := db.Where("value = ?", req.Token).Take(&dbToken)
if resp.Error != nil {
slog.Info("useAdminToken failed", "error", "token not found in db")
return errors.New("invalid token")
}
if dbToken.Type != TOKENTYPE_ADMIN {
slog.Info("useAdminToken failed", "error", "token is of wrong type", "type_wanted", TOKENTYPE_ADMIN, "type_actual", dbToken.Type)
return errors.New("invalid token")
}
dur := time.Since(dbToken.CreatedAt)
if dur > tokenMaxAge {
slog.Info("useAdminToken failed", "error", "token in db has expired")
return errors.New("invalid token")
}
if dbToken.UserID != userId {
slog.Info("useAdminToken failed", "error", "token in db is not for this user")
return errors.New("invalid token")
}
// Token is valid and for current user.. give user admin.
// Incase removing the token after used fails, this is in a transaction so user wont be admin.
err := db.Transaction(func(tx *gorm.DB) error {
// Give user admin
if err := tx.Model(&User{}).Where("id = ?", userId).Update("permissions", PERM_ADMIN).Error; err != nil {
return err
}
// Delete used token
if err := tx.Where("value = ?", req.Token).Delete(&Token{}).Error; err != nil {
return err
}
// commit transaction if no errors
return nil
})
if err != nil {
slog.Info("useAdminToken failed", "error", err, "error_pretty", "using token transaction failed")
return errors.New("failed to use token")
}
return nil
}
func signJWT(user *User) (token string, err error) {
// Create new jwt with claim data
jwt := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{
user.ID,
user.Username,
user.Type,
jwt.RegisteredClaims{
// ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
@@ -348,3 +517,11 @@ func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err erro
return p, salt, hash, nil
}
func hasPermission(perms int, reqPerm int) bool {
// Admins have permission for everything.
if perms&PERM_ADMIN == PERM_ADMIN {
return true
}
return (perms & reqPerm) == reqPerm
}
+118
View File
@@ -0,0 +1,118 @@
package main
import (
"encoding/json"
"errors"
"log"
"log/slog"
"os"
)
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"`
// Optional: Point to your Jellyfin install
// to enable it as an auth provider.
JELLYFIN_HOST string `json:",omitempty"`
// Enable/disable signup functionality.
// Set to `false` to disable registering an account.
SIGNUP_ENABLED bool `json:",omitempty"`
// Optional: Provide your own TMDB API Key.
// If unprovided, the default Watcharr API key will be used.
TMDB_KEY string `json:",omitempty"`
// Enable/disable debug logging. Useful for when trying
// to figure out exactly what the server is doing at a point
// of failure.
// Set to `true` to enable.
DEBUG bool `json:",omitempty"`
}
var (
// Our server config.. set defaults here, then `readConfig`
// will overwrite if provided in watcharr.json cfg file.
Config = ServerConfig{
SIGNUP_ENABLED: true,
}
)
// Read config file
// Calls generateConfig if file doesn't exist
func readConfig() error {
cfg, err := os.Open("./data/watcharr.json")
if err != nil {
if os.IsNotExist(err) {
slog.Info("Config file doesn't exist... generating.")
if err = generateConfig(); err == nil {
return nil
}
}
return err
}
defer cfg.Close()
jsonParser := json.NewDecoder(cfg)
if err = jsonParser.Decode(&Config); err != nil {
return err
}
initFromConfig()
return nil
}
// Ensure required config is provided
func initFromConfig() error {
if Config.JWT_SECRET == "" {
log.Fatal("JWT_SECRET missing from config!")
}
return nil
}
// Generate new barebones watcharr.json config file.
// Currently only JWT_SECRET is required, so this method
// generates a secret.
func generateConfig() error {
key, err := generateString(64)
if err != nil {
return err
}
cfg := ServerConfig{JWT_SECRET: key}
barej, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
return err
}
Config.JWT_SECRET = cfg.JWT_SECRET
return os.WriteFile("./data/watcharr.json", barej, 0755)
}
// Update server config property
func updateConfig(k string, v any) error {
slog.Debug("updateConfig", "k", k, "v", v)
if v == nil {
return errors.New("invalid value")
}
if k == "JELLYFIN_HOST" {
Config.JELLYFIN_HOST = v.(string)
} else if k == "SIGNUP_ENABLED" {
Config.SIGNUP_ENABLED = v.(bool)
} else if k == "TMDB_KEY" {
Config.TMDB_KEY = v.(string)
} else if k == "DEBUG" {
Config.DEBUG = v.(bool)
setLoggingLevel()
} else {
return errors.New("invalid setting")
}
return writeConfig()
}
// Write current Config to file
func writeConfig() error {
barej, err := json.MarshalIndent(Config, "", "\t")
if err != nil {
return err
}
return os.WriteFile("./data/watcharr.json", barej, 0755)
}
+94 -13
View File
@@ -2,6 +2,7 @@ package main
import (
"errors"
"log/slog"
"time"
)
@@ -20,7 +21,7 @@ type Content struct {
PosterPath string `json:"poster_path"`
Overview string `json:"overview"`
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
ReleaseDate time.Time `json:"release_date"`
ReleaseDate *time.Time `json:"release_date,omitempty"`
Popularity float32 `json:"popularity"`
VoteAverage float32 `json:"vote_average"`
VoteCount uint32 `json:"vote_count"`
@@ -33,23 +34,48 @@ type Content struct {
NumberOfSeasons uint32 `json:"numberOfSeasons"`
}
// 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.
func transformProviders(c *interface{}, country string) {
slog.Debug("transformProviders called", "country", country)
if cmap, ok := (*c).(map[string]interface{}); ok {
if rmap, ok := cmap["results"].(map[string]interface{}); ok {
if val, ok := rmap[country]; ok {
slog.Debug("transformProviders: Found country.. overwriting whole object", "new_obj", val)
if rvmap, ok := val.(map[string]interface{}); ok {
rvmap["country"] = country
}
*c = val
} else {
slog.Warn("transformProviders: Couldn't find country..", "country", country)
}
} else {
slog.Warn("transformProviders: Couldn't find results property..")
}
} else {
slog.Error("transformProviders: Assertion failed")
}
}
func searchContent(query string) (TMDBSearchMultiResponse, error) {
resp := new(TMDBSearchMultiResponse)
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
if err != nil {
println("Failed to complete multi search request!", err.Error())
slog.Error("Failed to complete multi search request!", "error", err.Error())
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
}
return *resp, nil
}
func movieDetails(id string) (TMDBMovieDetails, error) {
func movieDetails(id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
resp := new(TMDBMovieDetails)
err := tmdbRequest("/movie/"+id, map[string]string{"append_to_response": "videos"}, &resp)
err := tmdbRequest("/movie/"+id, rParams, &resp)
if err != nil {
println("Failed to complete movie details request!", err.Error())
slog.Error("Failed to complete movie details request!", "error", err.Error())
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
}
transformProviders(&resp.WatchProviders, country)
return *resp, nil
}
@@ -57,19 +83,20 @@ func movieCredits(id string) (TMDBContentCredits, error) {
resp := new(TMDBContentCredits)
err := tmdbRequest("/movie/"+id+"/credits", map[string]string{}, &resp)
if err != nil {
println("Failed to complete movie cast request!", err.Error())
slog.Error("Failed to complete movie cast request!", "error", err.Error())
return TMDBContentCredits{}, errors.New("failed to complete movie cast request")
}
return *resp, nil
}
func tvDetails(id string) (TMDBShowDetails, error) {
func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
resp := new(TMDBShowDetails)
err := tmdbRequest("/tv/"+id, map[string]string{"append_to_response": "videos"}, &resp)
err := tmdbRequest("/tv/"+id, rParams, &resp)
if err != nil {
println("Failed to complete tv details request!", err.Error())
slog.Error("Failed to complete tv details request!", "error", err.Error())
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
}
transformProviders(&resp.WatchProviders, country)
return *resp, nil
}
@@ -77,7 +104,7 @@ func tvCredits(id string) (TMDBContentCredits, error) {
resp := new(TMDBContentCredits)
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
if err != nil {
println("Failed to complete tv cast request!", err.Error())
slog.Error("Failed to complete tv cast request!", err.Error())
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
}
return *resp, nil
@@ -87,7 +114,7 @@ func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error)
resp := new(TMDBSeasonDetails)
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
if err != nil {
println("Failed to complete season details request!", err.Error())
slog.Error("Failed to complete season details request!", "error", err.Error())
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
}
return *resp, nil
@@ -97,7 +124,7 @@ func personDetails(id string) (TMDBPersonDetails, error) {
resp := new(TMDBPersonDetails)
err := tmdbRequest("/person/"+id, map[string]string{}, &resp)
if err != nil {
println("Failed to complete person details request!", err.Error())
slog.Error("Failed to complete person details request!", "error", err.Error())
return TMDBPersonDetails{}, errors.New("failed to complete person details request")
}
return *resp, nil
@@ -107,8 +134,62 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
resp := new(TMDBPersonCombinedCredits)
err := tmdbRequest("/person/"+id+"/combined_credits", map[string]string{}, &resp)
if err != nil {
println("Failed to complete person details request!", err.Error())
slog.Error("Failed to complete person details request!", "error", err.Error())
return TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
}
return *resp, nil
}
func discoverMovies() (TMDBDiscoverMovies, error) {
resp := new(TMDBDiscoverMovies)
err := tmdbRequest("/discover/movie/", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete discover movies request!", "error", err.Error())
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
}
return *resp, nil
}
func discoverTv() (TMDBDiscoverShows, error) {
resp := new(TMDBDiscoverShows)
err := tmdbRequest("/discover/tv/", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete discover tv request!", "error", err.Error())
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
}
return *resp, nil
}
func allTrending() (TMDBTrendingAll, error) {
resp := new(TMDBTrendingAll)
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete all trending request!", "error", err.Error())
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
}
return *resp, nil
}
func upcomingMovies() (TMDBUpcomingMovies, error) {
resp := new(TMDBUpcomingMovies)
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
if err != nil {
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
}
return *resp, nil
}
// Theres no upcoming endpoint for tv ;( - using discover with future dates
func upcomingTv() (TMDBUpcomingShows, error) {
resp := new(TMDBUpcomingShows)
dFmt := "2006-01-02"
mind := time.Now().Format(dFmt)
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
if err != nil {
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
}
return *resp, nil
}
+45 -33
View File
@@ -1,64 +1,76 @@
module github.com/sbondCo/Watcharr
go 1.20
go 1.21
require (
github.com/gin-contrib/cache v1.2.0
github.com/gin-contrib/cors v1.4.0
github.com/gin-gonic/gin v1.9.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.0.0
github.com/joho/godotenv v1.5.1
github.com/lib/pq v1.10.9
github.com/uptrace/bun v1.1.14
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
github.com/uptrace/bun/driver/sqliteshim v1.1.14
golang.org/x/crypto v0.9.0
gorm.io/driver/sqlite v1.5.1
gorm.io/gorm v1.25.1
github.com/uptrace/bun v1.1.16
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16
github.com/uptrace/bun/driver/sqliteshim v1.1.16
golang.org/x/crypto v0.14.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5
)
require (
github.com/bytedance/sonic v1.8.3 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bytedance/sonic v1.10.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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.11.2 // indirect
github.com/goccy/go-json v0.10.0 // indirect
github.com/google/uuid v1.3.0 // indirect
github.com/go-playground/validator/v10 v10.15.5 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/knz/go-libedit v1.10.1 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // 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.0.7 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.10 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.0 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/mod v0.10.0 // indirect
golang.org/x/net v0.10.0 // indirect
golang.org/x/sys v0.8.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/tools v0.9.1 // indirect
google.golang.org/protobuf v1.28.1 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/mod v0.13.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/text v0.13.0 // indirect
golang.org/x/tools v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lukechampine.com/uint128 v1.3.0 // indirect
modernc.org/cc/v3 v3.40.0 // indirect
modernc.org/ccgo/v3 v3.16.13 // indirect
modernc.org/libc v1.22.6 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect
modernc.org/cc/v3 v3.41.0 // indirect
modernc.org/cc/v4 v4.2.1 // indirect
modernc.org/ccgo/v3 v3.16.15 // indirect
modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d // indirect
modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083 // indirect
modernc.org/libc v1.24.1 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.7.2 // indirect
modernc.org/opt v0.1.3 // indirect
modernc.org/sqlite v1.22.1 // indirect
modernc.org/strutil v1.1.3 // indirect
modernc.org/sqlite v1.26.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)
+122
View File
@@ -1,16 +1,36 @@
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw=
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.8.3 h1:pf6fGl5eqWYKkx1RcD4qpuX+BIUaduv/wTm5ekWJ80M=
github.com/bytedance/sonic v1.8.3/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.0 h1:qtNZduETEIWJVIyDl01BeNxur2rW9OwTQ/yBqFRkKEk=
github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -18,6 +38,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -28,18 +50,28 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw=
github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs=
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/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.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@@ -55,6 +87,10 @@ github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBF
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
@@ -65,6 +101,8 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -77,6 +115,10 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
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/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 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
@@ -88,11 +130,17 @@ github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvI
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us=
github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
@@ -106,6 +154,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
@@ -116,26 +165,40 @@ github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.10 h1:eimT6Lsr+2lzmSZxPhLFoOWFmQqwk0fllJJ5hEbTXtQ=
github.com/ugorji/go/codec v1.2.10/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0=
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
github.com/uptrace/bun v1.1.16 h1:cn9cgEMFwcyYRsQLfxCRMUxyK1WaHwOVrR3TvzEFZ/A=
github.com/uptrace/bun v1.1.16/go.mod h1:7HnsMRRvpLFUcquJxp22JO8PsWKpFQO/gNXqqsuGWg8=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12 h1:Ud31nqZmebcQpl151nb108+vtcpxJ7kfXmbPYbALBiI=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12/go.mod h1:Pwg7s31BdF3PMBlWTnYkEn2I9ASsvatt1Ln/AERCTV4=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16 h1:gbc9BP/e4sNOB9VBj+Si46dpOz2oktmZPidkda92GYY=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16/go.mod h1:YNezpK7fIn5Wa2WGmTCZ/nEyiswcXmuT4iNWADeL1x4=
github.com/uptrace/bun/driver/sqliteshim v1.1.12 h1:GMbSa7Pjjk4kjF8XURz5uMLe2PbN98e6t00sp0rx2Eo=
github.com/uptrace/bun/driver/sqliteshim v1.1.12/go.mod h1:u67g2ewzoMDCCAqjliHAM/BJjEXfoExXlFXhx3TnXRs=
github.com/uptrace/bun/driver/sqliteshim v1.1.14 h1:DFPUJ6KjDP2myjq15gtYYNngmAFMww1Y2UFZv4tbUw8=
github.com/uptrace/bun/driver/sqliteshim v1.1.14/go.mod h1:5BFN7V6Sm37Tn7UE4FWNm/F6V3iJPUzAJ7QyRwA5b1k=
github.com/uptrace/bun/driver/sqliteshim v1.1.16 h1:dhXrdXQegGSM+Jk07s9p1Y575DMhCKl7wBkss8kgM+8=
github.com/uptrace/bun/driver/sqliteshim v1.1.16/go.mod h1:7fJaPqaiSvnWytQcMwVv1ZcjUJh7tGDonO/zq3O6RRI=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/msgpack/v5 v5.4.0 h1:hRM0digJwyR6vll33NNAwCFguy5JuBD6jxDmQP3l608=
github.com/vmihailenco/msgpack/v5 v5.4.0/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
@@ -143,12 +206,19 @@ golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/exp v0.0.0-20181106170214-d68db9428509/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
@@ -156,6 +226,10 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -168,6 +242,10 @@ golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -177,20 +255,30 @@ golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -200,6 +288,10 @@ gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=
gorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ=
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc=
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s=
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
@@ -207,34 +299,64 @@ gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.3 h1:zi4rHZj1anhZS2EuEODMhDisGy+Daq9jtPrNGgbQYD8=
gorm.io/gorm v1.25.3/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
modernc.org/cc/v4 v4.2.1 h1:xwwaXFwiPaVZpGRMd19NPLsaiNyNBO8oChey4501g1M=
modernc.org/cc/v4 v4.2.1/go.mod h1:0O8vuqhQfwBy+piyfEjzWIUGV4I3TPsXSf0W05+lgN8=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d h1:3yB/pQNL5kVPDifGFqoZjeRxf8m0+Us15rB7ertNASQ=
modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d/go.mod h1:austqj6cmEDRfewsUvmGmyIgsI/Nq87oTXlfTgY85Fc=
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083 h1:rGoLVwiOxdeVkGYMOF/8Pw7xpDd3OqScJU/tqHgvY1c=
modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083/go.mod h1:Zt5HLUW0j+l02wj99UsPs+1DOFwwsGnqfcw+BGyyP/A=
modernc.org/lex v1.1.0/go.mod h1:+ojes+j0JYCaqwKYCBjcUavscJHmWFKvViUTMU4VjLA=
modernc.org/lexer v1.0.0/go.mod h1:F/Dld0YKYdZCLQ7bD0USbWL4YKCyTDRDHiDTOs0q0vk=
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
modernc.org/libc v1.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo=
modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/scannertest v1.0.0/go.mod h1:9qnOCV+wSvq1o9hcOPNwRorND4qpZdtmTvmcdKyN3iE=
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow=
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+16
View File
@@ -0,0 +1,16 @@
package main
import (
"crypto/rand"
b64 "encoding/base64"
)
// Generate a random string
func generateString(len int) (string, error) {
key := make([]byte, len)
_, err := rand.Read(key)
if err != nil {
return "", err
}
return b64.StdEncoding.EncodeToString([]byte(key)), nil
}
+128
View File
@@ -0,0 +1,128 @@
package main
import (
"errors"
"log/slog"
"strconv"
"strings"
"gorm.io/gorm"
)
type ImportResponseType string
var (
// Successful import
IMPORT_SUCCESS ImportResponseType = "IMPORT_SUCCESS"
// Import failed for reasons user cant fix
IMPORT_FAILED ImportResponseType = "IMPORT_FAILED"
// Import query returned multiple results, user must decide
IMPORT_MULTI ImportResponseType = "IMPORT_MULTI"
// Import query returned zero results, user must provide more info
IMPORT_NOTFOUND ImportResponseType = "IMPORT_NOTFOUND"
// Item already exists so couldn't import (unique constraint hit when adding)
IMPORT_EXISTS ImportResponseType = "IMPORT_EXISTS"
)
type ImportRequest struct {
Name string `json:"name"`
TmdbID int `json:"tmdbId"`
Type ContentType `json:"type"`
}
type ImportResponse struct {
Type ImportResponseType `json:"type"`
Results []TMDBSearchMultiResults `json:"results"`
Match TMDBSearchMultiResults `json:"match"`
// On success this will be filled with the new watched entry
WatchedEntry Watched `json:"watchedEntry"`
}
func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse, error) {
// If tmdbId and type passed in request body
// we dont need to use a search tmdb request.
// Retrieve the details directly.
if ar.TmdbID != 0 && (ar.Type == MOVIE || ar.Type == SHOW) {
tid := strconv.Itoa(ar.TmdbID)
if ar.Type == MOVIE {
cr, err := movieDetails(tid, "", map[string]string{})
if err != nil {
return ImportResponse{}, errors.New("movie details request failed")
}
slog.Debug("import: by tmdbid of movie", "cr", cr)
return successfulImport(db, userId, cr.ID, MOVIE)
} else if ar.Type == SHOW {
cr, err := tvDetails(tid, "", map[string]string{})
if err != nil {
return ImportResponse{}, errors.New("tv details request failed")
}
slog.Debug("import: by tmdbid of tv", "cr", cr)
return successfulImport(db, userId, cr.ID, SHOW)
}
}
sr, err := searchContent(ar.Name)
if err != nil {
slog.Error("import: content search failed", "error", err)
return ImportResponse{}, errors.New("Content search failed")
}
pMatches := []TMDBSearchMultiResults{}
for _, r := range sr.Results {
if r.MediaType != "person" {
pMatches = append(pMatches, r)
}
}
resLen := len(pMatches)
slog.Debug("import: potential matches", "num_found", resLen)
if resLen <= 0 {
slog.Debug("import: returning IMPORT_NOTFOUND")
return ImportResponse{Type: IMPORT_NOTFOUND}, nil
} else if resLen > 1 {
slog.Debug("import: multiple results found")
// If there are multiple responses, but only one item
// from the results is a 100% match for the imported
// items name, then consider successful match with that.
var perfectMatch TMDBSearchMultiResults
for _, r := range pMatches {
itemName := r.Name
if itemName == "" {
itemName = r.Title
}
if strings.EqualFold(itemName, ar.Name) {
slog.Debug("import: multiple results processing: found a perfectMatch", "match", r)
if perfectMatch.ID != 0 {
// If perfect match has been set before..
// quit looking and just show all results.
slog.Debug("import: multiple results processing: Second perfectMatch found.. returning all results")
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
}
perfectMatch = r
}
}
// If one perfect match found, import it
if perfectMatch.ID != 0 {
slog.Debug("import: importing from perfect match")
return successfulImport(db, userId, perfectMatch.ID, ContentType(perfectMatch.MediaType))
}
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
} else {
slog.Debug("import: success.. only found one result")
return successfulImport(db, userId, pMatches[0].ID, ContentType(pMatches[0].MediaType))
}
}
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType) (ImportResponse, error) {
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: contentId,
ContentType: contentType,
}, IMPORTED_WATCHED)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("successfulImport: unique constraint hit.. show must already be on watch list", "error", err)
return ImportResponse{Type: IMPORT_EXISTS}, nil
}
slog.Error("successfulImport: Failed to add content as watched", "error", err)
return ImportResponse{Type: IMPORT_FAILED}, nil
}
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
)
type JellyfinItemSearchResponse struct {
Items []JellyfinItems `json:"Items"`
}
type JellyfinItems struct {
Type string `json:"Type"`
ServerID string `json:"ServerId"`
Id string `json:"Id"`
ProviderIds struct {
Tmdb string `json:"Tmdb"`
} `json:"ProviderIds"`
}
type JFContentFindResponse struct {
HasContent bool `json:"hasContent"`
Url string `json:"url"`
}
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
if Config.JELLYFIN_HOST == "" {
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
return errors.New("jellyfin not enabled")
}
slog.Debug("jellyfinAPIRequest", "endpoint", ep, "params", p)
base, err := url.Parse(Config.JELLYFIN_HOST)
if err != nil {
return errors.New("failed to parse api uri")
}
// Path params
base.Path += ep
// Query params
params := url.Values{}
for k, v := range p {
params.Add(k, v)
}
// Add params to url
base.RawQuery = params.Encode()
// Run get request
client := &http.Client{}
req, err := http.NewRequest(method, base.String(), bytes.NewBuffer([]byte{}))
if err != nil {
slog.Error("Creating request to jellyfin failed", "error", err)
return errors.New("request failed")
}
req.Header.Add("Content-Type", "application/json")
authHeader := "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor" + username + "\", Version=\"10.8.0\""
if userToken != "" {
authHeader += ", Token=\"" + userToken + "\""
}
req.Header.Add("X-Emby-Authorization", authHeader)
res, err := client.Do(req)
if err != nil {
slog.Error("making request to jellyfin for auth failed", "error", err)
return errors.New("request failed")
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
slog.Error("Error reading jellyfin auth response", "error", err.Error())
return err
}
if res.StatusCode != 200 {
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
return errors.New("incorrect details")
}
// Unmarshal response
err = json.Unmarshal([]byte(body), &resp)
if err != nil {
return err
}
return nil
}
func jellyfinContentFind(
userId uint,
userType UserType,
username string,
userThirdPartyId string,
userThirdPartyAuth string,
contentType string,
contentName string,
contentTmdbId string,
) (JFContentFindResponse, error) {
if Config.JELLYFIN_HOST == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
}
if userType != JELLYFIN_USER || userThirdPartyId == "" {
slog.Error("User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
return JFContentFindResponse{}, errors.New("not jellyfin user")
}
if userThirdPartyAuth == "" {
slog.Error("User has no thirdPartyAuth token..")
return JFContentFindResponse{}, errors.New("user has no jellyfin auth token")
}
if contentType == "" || contentName == "" {
slog.Error("Bad request", "content_type", contentType, "content_name", contentName)
return JFContentFindResponse{}, errors.New("content type or name not provided")
}
if contentType == "tv" {
contentType = "series"
}
resp := new(JellyfinItemSearchResponse)
err := jellyfinAPIRequest(
"GET",
"/Users/"+userThirdPartyId+"/Items",
map[string]string{
"searchTerm": contentName,
"IncludePeople": "false",
"IncludeMedia": "true",
"IncludeGenres": "false",
"IncludeStudios": "false",
"IncludeArtists": "false",
"IncludeItemTypes": contentType,
"Limit": "5",
"Fields": "ProviderIds",
"Recursive": "true",
"EnableTotalRecordCount": "false",
"ImageTypeLimit": "0",
},
username,
userThirdPartyAuth,
&resp,
)
if err != nil {
slog.Error("jellyfinContentFind: Jellyfin API request failed", "error", err)
return JFContentFindResponse{}, errors.New("failed to get jellyfin response")
}
// Find true match from jf search results
ret := new(JFContentFindResponse)
ret.HasContent = false
ret.Url = ""
for _, i := range resp.Items {
if i.ProviderIds.Tmdb == contentTmdbId {
ret.HasContent = true
ret.Url = Config.JELLYFIN_HOST + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
}
}
return *ret, nil
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"log/slog"
"github.com/gin-gonic/gin"
)
// 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")
c.Next()
}
}
+3 -2
View File
@@ -2,6 +2,7 @@ package main
import (
"errors"
"log/slog"
"time"
"gorm.io/gorm"
@@ -18,13 +19,13 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
user := new(User)
res := db.Model(&User{}).Where("id = ?", userId).Take(&user)
if res.Error != nil {
println("Failed to get profile:", res.Error.Error())
slog.Error("Failed to get profile:", "error", res.Error.Error())
return Profile{}, errors.New("failed to get profile")
}
watched := new([]Watched)
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
if res.Error != nil {
println("Profile: Failed to get watched for processing:", res.Error.Error())
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
return Profile{}, errors.New("failed to get watched for processing")
}
var (
+296 -32
View File
@@ -1,9 +1,13 @@
package main
import (
"log/slog"
"net/http"
"strconv"
"time"
"github.com/gin-contrib/cache"
"github.com/gin-contrib/cache/persistence"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -12,6 +16,11 @@ type ErrorResponse struct {
Error string `json:"error"`
}
type KeyValueRequest struct {
Key string `json:"key"`
Value any `json:"value"`
}
type BaseRouter struct {
db *gorm.DB
rg *gin.RouterGroup
@@ -24,17 +33,47 @@ func newBaseRouter(db *gorm.DB, rg *gin.RouterGroup) *BaseRouter {
}
}
func (b *BaseRouter) addContentRoutes() {
content := b.rg.Group("/content").Use(AuthRequired())
// Since we cannot remove these setup routes after they are registered,
// each route/service should ensure we are still in setup before continuing.
// After server restart, these routes shouldn't exist if setup finished
// (currently it is finished if a user is created).
//
// Each controller can check ServerInSetup var first, then each service
// can double check what it needs to (eg create_admin service, registerFirstUser,
// will check that no users exist).
func (b *BaseRouter) addSetupRoutes() {
setup := b.rg.Group("/setup")
// Get trending content
// content.GET("/", func(c *gin.Context) {
// c.JSON(http.StatusOK, getWatched(b.db))
// })
setup.POST("/create_admin", func(c *gin.Context) {
if !ServerInSetup {
c.JSON(http.StatusForbidden, ErrorResponse{Error: "not in setup"})
return
}
var user UserRegisterRequest
if c.ShouldBindJSON(&user) == nil {
response, err := registerFirstUser(&user, b.db)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
} else {
// Set in setup to false after first user registered successfully
ServerInSetup = false
}
c.JSON(http.StatusOK, response)
return
}
c.Status(400)
})
}
func (b *BaseRouter) addContentRoutes() {
content := b.rg.Group("/content").Use(AuthRequired(nil))
exp := time.Hour * 24
store := persistence.NewInMemoryStore(exp)
// Search for content
content.GET("/:query", func(c *gin.Context) {
println(c.Param("query"))
content.GET("/:query", cache.CachePage(store, exp, func(c *gin.Context) {
// println(c.Param("query"))
if c.Param("query") == "" {
c.Status(400)
return
@@ -45,24 +84,24 @@ func (b *BaseRouter) addContentRoutes() {
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get movie details (for movie page)
content.GET("/movie/:id", func(c *gin.Context) {
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
}
content, err := movieDetails(c.Param("id"))
content, err := movieDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get movie cast
content.GET("/movie/:id/credits", func(c *gin.Context) {
content.GET("/movie/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -73,24 +112,24 @@ func (b *BaseRouter) addContentRoutes() {
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get tv details (for tv page)
content.GET("/tv/:id", func(c *gin.Context) {
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
}
content, err := tvDetails(c.Param("id"))
content, err := tvDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get tv cast
content.GET("/tv/:id/credits", func(c *gin.Context) {
content.GET("/tv/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -101,10 +140,10 @@ func (b *BaseRouter) addContentRoutes() {
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get season details
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
content.GET("/tv/:id/season/:num", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" || c.Param("num") == "" {
c.Status(400)
return
@@ -115,10 +154,10 @@ func (b *BaseRouter) addContentRoutes() {
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get person details
content.GET("/person/:id", func(c *gin.Context) {
content.GET("/person/:id", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -129,10 +168,10 @@ func (b *BaseRouter) addContentRoutes() {
return
}
c.JSON(http.StatusOK, content)
})
}))
// Get person credits
content.GET("/person/:id/credits", func(c *gin.Context) {
content.GET("/person/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
if c.Param("id") == "" {
c.Status(400)
return
@@ -143,23 +182,88 @@ func (b *BaseRouter) addContentRoutes() {
return
}
c.JSON(http.StatusOK, content)
})
}))
// Discover movies
content.GET("/discover/movies", cache.CachePage(store, exp, func(c *gin.Context) {
content, err := discoverMovies()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Discover shows
content.GET("/discover/tv", cache.CachePage(store, exp, func(c *gin.Context) {
content, err := discoverTv()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Get all trending (movies, tv, people)
content.GET("/trending", cache.CachePage(store, exp, func(c *gin.Context) {
content, err := allTrending()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Upcoming Movies
content.GET("/upcoming/movies", cache.CachePage(store, exp, func(c *gin.Context) {
content, err := upcomingMovies()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
// Upcoming Tv
content.GET("/upcoming/tv", cache.CachePage(store, exp, func(c *gin.Context) {
content, err := upcomingTv()
if err != nil {
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, content)
}))
}
func (b *BaseRouter) addWatchedRoutes() {
watched := b.rg.Group("/watched").Use(AuthRequired())
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
watched.GET("", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
c.JSON(http.StatusOK, getWatched(b.db, userId))
})
watched.GET(":id/:username", func(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
slog.Error("getPublicWatched route failed to convert id param to uint", "id", id)
c.Status(400)
return
}
response, err := getPublicWatched(b.db, uint(id), c.Param("username"))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
watched.POST("", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var ar WatchedAddRequest
err := c.ShouldBindJSON(&ar)
if err == nil {
response, err := addWatched(b.db, userId, ar)
response, err := addWatched(b.db, userId, ar, ADDED_WATCHED)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
@@ -212,7 +316,7 @@ func (b *BaseRouter) addWatchedRoutes() {
}
func (b *BaseRouter) addActivityRoutes() {
activity := b.rg.Group("/activity").Use(AuthRequired())
activity := b.rg.Group("/activity").Use(AuthRequired(nil))
activity.GET(":watchedId", func(c *gin.Context) {
watchedId, err := strconv.ParseUint(c.Param("watchedId"), 10, 32)
@@ -281,9 +385,9 @@ func (b *BaseRouter) addAuthRoutes() {
// Register
auth.POST("/register", func(c *gin.Context) {
var user User
var user UserRegisterRequest
if c.ShouldBindJSON(&user) == nil {
response, err := register(&user, b.db)
response, err := register(&user, PERM_NONE, b.db)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
@@ -296,12 +400,48 @@ func (b *BaseRouter) addAuthRoutes() {
// Get available auth providers
auth.GET("/available", func(c *gin.Context) {
c.JSON(http.StatusOK, AvailableAuthProviders)
availableAuthProviders := []string{}
if Config.JELLYFIN_HOST != "" {
availableAuthProviders = append(availableAuthProviders, "jellyfin")
}
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
AvailableAuthProviders: availableAuthProviders,
SignupEnabled: Config.SIGNUP_ENABLED,
IsInSetup: ServerInSetup,
})
})
// Request admin token
auth.Use(AuthRequired(nil)).GET("/admin_token", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
c.Status(http.StatusNoContent)
})
// Use admin token
auth.Use(AuthRequired(nil)).POST("/admin_token", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var atr UseAdminTokenRequest
if c.ShouldBindJSON(&atr) == nil {
err := useAdminToken(&atr, b.db, userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusNoContent)
return
}
c.Status(400)
})
}
func (b *BaseRouter) addProfileRoutes() {
profile := b.rg.Group("/profile").Use(AuthRequired())
profile := b.rg.Group("/profile").Use(AuthRequired(nil))
// Get user profile details
profile.GET("", func(c *gin.Context) {
@@ -314,3 +454,127 @@ func (b *BaseRouter) addProfileRoutes() {
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addJellyfinRoutes() {
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db))
// Check if jf has item
jf.GET("/:type/:name/:tmdbId", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
userType := c.MustGet("userType").(UserType)
username := c.MustGet("username").(string)
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
response, err := jellyfinContentFind(userId, userType, username, userThirdPartyId, userThirdPartyAuth, c.Param("type"), c.Param("name"), c.Param("tmdbId"))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addUserRoutes() {
u := b.rg.Group("/user").Use(AuthRequired(b.db))
// Get current user info
u.GET("", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
response, err := getUserInfo(b.db, userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Update current user settings
u.POST("/update", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var ur UserSettings
err := c.ShouldBindJSON(&ur)
if err == nil {
response, err := userUpdate(b.db, userId, ur)
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()})
})
// Get current user setting
u.GET("/settings", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
response, err := userGetSettings(b.db, userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
// Search users
u.GET("/search/:query", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
response, err := userSearch(b.db, userId, c.Param("query"))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addImportRoutes() {
imprt := b.rg.Group("/import").Use(AuthRequired(nil))
imprt.POST("", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var ar ImportRequest
err := c.ShouldBindJSON(&ar)
if err == nil {
response, err := importContent(b.db, userId, ar)
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() {
server := b.rg.Group("/server").Use(AuthRequired(b.db), AdminRequired())
// Get server config (minus very sensitive fields, like JWT_SECRET)
server.GET("/config", func(c *gin.Context) {
// Return new ServerConfig with only the fields we want to show in settings ui
c.JSON(http.StatusOK, ServerConfig{
SIGNUP_ENABLED: Config.SIGNUP_ENABLED,
JELLYFIN_HOST: Config.JELLYFIN_HOST,
TMDB_KEY: Config.TMDB_KEY,
DEBUG: Config.DEBUG,
})
})
// Update config
server.POST("/config", func(c *gin.Context) {
var ur KeyValueRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
err := updateConfig(ur.Key, ur.Value)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
}
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"time"
"gorm.io/gorm"
)
// Setup recurring tasks (eg cleanup every x mins)
func setupTasks(db *gorm.DB) {
taskRunInterval := 1 * time.Minute
ticker := time.NewTicker(taskRunInterval)
defer ticker.Stop()
for range ticker.C {
// Runs funcs that are in the place where we are cleaning.
// Bit cleaner and we can keep the related code close to its home.
cleanupTokens(db)
}
}
+184 -5
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/url"
"time"
@@ -86,7 +87,9 @@ type TMDBMovieDetails struct {
Video bool `json:"video"`
// Extra items because we use `append_to_response` on the request
Videos TMDBContentVideos `json:"videos"`
Videos TMDBContentVideos `json:"videos"`
WatchProviders interface{} `json:"watch/providers"`
Similar TMDBMovieSimilar `json:"similar"`
}
type TMDBShowDetails struct {
@@ -139,7 +142,15 @@ type TMDBShowDetails struct {
Type string `json:"type"`
// Extra items because we use `append_to_response` on the request
Videos TMDBContentVideos `json:"videos"`
Videos TMDBContentVideos `json:"videos"`
WatchProviders interface{} `json:"watch/providers"`
Similar TMDBShowSimilar `json:"similar"`
}
type WatchProvider struct {
ProviderID int `json:"provider_id"`
ProviderName string `json:"provider_name"`
DisplayPriority int `json:"display_priority"`
}
type TMDBContentVideos struct {
@@ -208,6 +219,50 @@ type TMDBSeasonDetails struct {
SeasonNumber int `json:"season_number"`
}
type TMDBShowSimilar 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 uint32 `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBMovieSimilar 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 uint32 `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBPersonDetails struct {
Birthday string `json:"birthday"`
KnownForDepartment string `json:"known_for_department"`
@@ -287,8 +342,132 @@ type TMDBContentCredits struct {
} `json:"crew"`
}
type TMDBDiscoverMovies 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"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBDiscoverShows struct {
Page int `json:"page"`
Results []struct {
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
Name string `json:"name"`
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"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBTrendingAll struct {
Page int `json:"page"`
Results []struct {
Adult bool `json:"adult"`
BackdropPath string `json:"backdrop_path"`
ID int `json:"id"`
Title string `json:"title,omitempty"`
OriginalLanguage string `json:"original_language"`
OriginalTitle string `json:"original_title,omitempty"`
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,omitempty"`
Video bool `json:"video,omitempty"`
VoteAverage float64 `json:"vote_average"`
VoteCount int `json:"vote_count"`
Name string `json:"name,omitempty"`
OriginalName string `json:"original_name,omitempty"`
FirstAirDate string `json:"first_air_date,omitempty"`
OriginCountry []string `json:"origin_country,omitempty"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBUpcomingMovies struct {
Dates struct {
Maximum string `json:"maximum"`
Minimum string `json:"minimum"`
} `json:"dates"`
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 float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
type TMDBUpcomingShows struct {
Page int `json:"page"`
Results []struct {
BackdropPath string `json:"backdrop_path"`
FirstAirDate string `json:"first_air_date"`
GenreIds []int `json:"genre_ids"`
ID int `json:"id"`
Name string `json:"name"`
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"`
VoteAverage float32 `json:"vote_average"`
VoteCount int `json:"vote_count"`
} `json:"results"`
TotalPages int `json:"total_pages"`
TotalResults int `json:"total_results"`
}
func getTMDBKey() string {
if Config.TMDB_KEY != "" {
return Config.TMDB_KEY
}
return "d047fa61d926371f277e7a83c9c4ff2c"
}
func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
println("tmdbAPIRequest:", ep)
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
base, err := url.Parse("https://api.themoviedb.org/3")
if err != nil {
return nil, errors.New("failed to parse api uri")
@@ -299,7 +478,7 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
// Query params
params := url.Values{}
params.Add("api_key", "d047fa61d926371f277e7a83c9c4ff2c")
params.Add("api_key", getTMDBKey())
params.Add("language", "en-US")
for k, v := range p {
params.Add(k, v)
@@ -319,7 +498,7 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
return nil, err
}
if res.StatusCode != 200 {
println("TMDB non 200 status code:", res.StatusCode)
slog.Error("TMDB non 200 status code:", "status_code", res.StatusCode)
return nil, errors.New(string(body))
}
return body, nil
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"errors"
"log/slog"
"time"
"gorm.io/gorm"
)
type TokenType string
var (
TOKENTYPE_ADMIN TokenType = "ADMIN"
)
type Token struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time `json:"createdAt"`
Value string `gorm:"not null"`
Type TokenType `gorm:"not null"`
UserID uint `gorm:"not null"`
}
const tokenMaxAge = 2 * time.Minute
func createOneUseToken(db *gorm.DB, t TokenType, userId uint) (string, error) {
token, err := generateString(8)
if err != nil {
slog.Error("createOneUseToken: Failed to generate string!", "error", err)
return "", errors.New("failed to generate token")
}
res := db.Create(&Token{Type: t, Value: token, UserID: userId})
if res.Error != nil {
slog.Error("createOneUseToken: Failed to insert token into db!", "error", res.Error)
return "", errors.New("failed to generate token")
}
return token, nil
}
// Cleans up tokens older than 2m.
func cleanupTokens(db *gorm.DB) {
slog.Debug("cleanupTokens: Cleaning up old tokens from db")
twoMinsAgo := time.Now().Add(-tokenMaxAge)
resp := db.Where("created_at < ?", twoMinsAgo).Delete(&Token{})
if resp.Error != nil {
slog.Error("cleanupTokens: Failed to run DELETE on old tokens!", "error", resp.Error)
}
}
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"errors"
"log/slog"
"gorm.io/gorm"
)
// Public user details for search results
type PublicUser struct {
ID uint `json:"id"`
Username string `json:"username"`
}
// Private user details, for returning users details to themselves
type PrivateUser struct {
Username string `json:"username"`
Type UserType `json:"type"`
Permissions int `json:"permissions"`
}
// Update user settings
func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error) {
slog.Debug("user update request running", "user_id", userId, "ur", ur)
user := new(User)
res := db.Where("id = ?", userId).Take(&user)
if res.Error != nil {
slog.Error("user update failed", "user_id", userId, "error", "failed to retrieve user from database")
return UserSettings{}, errors.New("failed to retrieve user")
}
if ur.HideSpoilers != nil {
user.HideSpoilers = ur.HideSpoilers
}
if ur.Private != nil {
user.Private = ur.Private
}
db.Save(&user)
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
}
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
slog.Debug("user update request running", "user_id", userId)
user := new(User)
res := db.Where("id = ?", userId).Take(&user)
if res.Error != nil {
slog.Error("user get failed", "user_id", userId, "error", "failed to retrieve user from database")
return UserSettings{}, errors.New("failed to retrieve user")
}
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
}
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
slog.Debug("user search request running", "query", q)
users := new([]PublicUser)
res := db.Where("private = 0 AND username LIKE ? AND id != ?", "%"+q+"%", currentUsersId).Table("users").Find(&users)
if res.Error != nil {
slog.Error("user search failed", "error", "failed to query database")
return []PublicUser{}, errors.New("failed to find users")
}
return *users, nil
}
func getUserInfo(db *gorm.DB, currentUsersId uint) (PrivateUser, error) {
slog.Debug("user get info request running")
user := new(PrivateUser)
res := db.Where("id = ?", currentUsersId).Table("users").Take(&user)
if res.Error != nil {
slog.Error("user get info failed", "error", res.Error)
return PrivateUser{}, errors.New("failed to find current user")
}
return *user, nil
}
+72 -21
View File
@@ -3,7 +3,9 @@ package main
import (
"bufio"
"fmt"
"io"
"log"
"log/slog"
"net/http"
"net/http/httputil"
"os"
@@ -13,6 +15,7 @@ import (
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"
"gopkg.in/natefinch/lumberjack.v2"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
@@ -24,16 +27,28 @@ type GormModel struct {
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
var AvailableAuthProviders = []string{}
var (
ServerInSetup = false
logLevel = new(slog.LevelVar)
)
func main() {
fmt.Println("Watcharr Starting")
err := godotenv.Load()
if err != nil {
log.Fatal("Failed to load vars from .env file:", err)
// Do not fail if file does not exist
if !os.IsNotExist(err) {
log.Fatal("Failed to load vars from .env file:", err)
}
}
ensureEnv()
multiw := setupLogging()
slog.Info("Watcharr Starting")
if err = readConfig(); err != nil {
log.Fatal("Failed to read server config!", err)
}
setLoggingLevel()
// Ensure data dir exists
err = ensureDirExists("./data")
@@ -44,15 +59,16 @@ func main() {
// Check if we want to be in DEV or PROD
isProd := true
if os.Getenv("MODE") == "DEV" {
slog.Info("Starting in DEV mode")
isProd = false
}
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
if err != nil {
panic("failed to connect to database")
log.Fatal("Failed to connect to database:", err)
}
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &Activity{})
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &Activity{}, &Token{})
if err != nil {
log.Fatal("Failed to auto migrate database:", err)
}
@@ -61,6 +77,7 @@ func main() {
go runUI()
gin.SetMode(gin.ReleaseMode)
}
gin.DefaultWriter = multiw
gine := gin.Default()
gine.Use(cors.New(cors.Config{
AllowOrigins: []string{"*"},
@@ -82,22 +99,67 @@ func main() {
})
}
br := newBaseRouter(db, gine.Group("/api"))
// Only add setup routes if there are no users found in db.
var userCount int64
if uresp := db.Model(&User{}).Count(&userCount); uresp.Error == nil {
if userCount != 0 {
slog.Debug("registered users found.. skipped creating setup routes.")
} else {
slog.Info("No users found.. creating setup routes.")
ServerInSetup = true
br.addSetupRoutes()
}
} else {
slog.Error("Failed to check if any users exist.. not registering setup routes", "error", uresp.Error)
}
br.addAuthRoutes()
br.addContentRoutes()
br.addWatchedRoutes()
br.addActivityRoutes()
br.addProfileRoutes()
br.addJellyfinRoutes()
br.addUserRoutes()
br.addImportRoutes()
br.addServerRoutes()
br.rg.Static("/img", "./data/img")
go setupTasks(db)
gine.Run("0.0.0.0:3080")
}
// Setup slog defaults
func setupLogging() io.Writer {
// logLevel = new(slog.LevelVar)
multiw := io.MultiWriter(&lumberjack.Logger{
Filename: "./data/watcharr.log",
MaxSize: 1, // megabytes
MaxBackups: 3,
MaxAge: 28, // days
Compress: false,
}, os.Stdout)
slog.SetDefault(slog.New(
slog.NewTextHandler(multiw, &slog.HandlerOptions{Level: logLevel}),
))
return multiw
}
// Set loggin level from config
func setLoggingLevel() {
if Config.DEBUG {
logLevel.Set(slog.LevelDebug)
} else {
logLevel.Set(slog.LevelInfo)
}
slog.Info("Logging level set", "logging_level", logLevel)
}
// Run UI server
func runUI() {
cmd := exec.Command("node", "ui/index.js")
cmdReader, err := cmd.StdoutPipe()
if err != nil {
log.Fatal("UI ERR ", err)
log.Fatal("UI ERR @ get stdout read pipe: ", err)
}
scanner := bufio.NewScanner(cmdReader)
go func() {
@@ -106,21 +168,10 @@ func runUI() {
}
}()
if err := cmd.Start(); err != nil {
log.Fatal("UI ERR ", err)
log.Fatal("UI ERR @ command start: ", err)
}
if err := cmd.Wait(); err != nil {
log.Fatal("UI ERR ", err)
}
}
// Ensure all required environment variables are set.
func ensureEnv() {
if os.Getenv("JWT_SECRET") == "" {
log.Fatal("JWT_SECRET env var missing!")
}
if os.Getenv("JELLYFIN_HOST") != "" {
AvailableAuthProviders = append(AvailableAuthProviders, "jellyfin")
log.Fatal("UI ERR @ command wait: ", err)
}
}
+52 -24
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path"
@@ -67,19 +68,45 @@ func getWatched(db *gorm.DB, userId uint) []Watched {
return *watched
}
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error) {
println(ar.ContentType, ar.ContentID)
// 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)
// First we need to make sure the users list is public
user := new(User)
// Figure we require knowlege of the users id and name to make it
// harder to just type in random ids and see someones list.. dunno
// 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")
return []Watched{}, errors.New("failed to check privacy settings")
}
if user.Private != nil && *user.Private {
slog.Error("getPublicWatched attempted to get a private list")
return []Watched{}, errors.New("this watched list is private")
}
// Now we know the user is public, return their list
watched := new([]Watched)
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
if res.Error != nil {
panic(res.Error)
}
return *watched, nil
}
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
var content Content
db.Where("tmdb_id = ?", ar.ContentID).Find(&content)
// Create content if not found from our db
if content == (Content{}) {
println("Content not in db, fetching...")
slog.Debug("Content not in db, fetching...")
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
if err != nil {
fmt.Printf("addWatched tmdb api request failed: %+v", err)
slog.Error("addWatched content tmdb api request failed", "error", err)
return Watched{}, errors.New("failed to find requested media")
}
@@ -106,7 +133,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
content := new(TMDBMovieDetails)
err = json.Unmarshal([]byte(resp), &content)
if err != nil {
println("Failed to unmarshal movie details:", err)
slog.Error("Failed to unmarshal movie details", "error", err)
return Watched{}, errors.New("failed to process movie details response")
}
id = content.ID
@@ -115,7 +142,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
title = content.Title
releaseDate, err = time.Parse(dateFormat, content.ReleaseDate)
if err != nil {
println("Failed to parse movie release date:", err)
slog.Error("Failed to parse movie release date", "error", err)
}
popularity = content.Popularity
voteAverage = content.VoteAverage
@@ -129,7 +156,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
content := new(TMDBShowDetails)
err = json.Unmarshal(resp, &content)
if err != nil {
println("Failed to unmarshal show details:", err)
slog.Error("Failed to unmarshal show details", "error", err)
return Watched{}, errors.New("failed to process show details response")
}
id = content.ID
@@ -138,7 +165,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
title = content.Name
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
if err != nil {
println("Failed to parse tv release date:", err)
slog.Error("Failed to parse tv release date", "error", err)
}
popularity = content.Popularity
voteAverage = content.VoteAverage
@@ -151,9 +178,9 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
numberOfSeasons = content.NumberOfSeasons
}
// Save the content in our db
println("id, etc:", id, title, overview, posterPath, "<-- end")
slog.Info("Saving content to db", "id", id, "title", title)
if id == 0 || title == "" {
println("addWatched, returned content missing id or title!", id, title)
slog.Error("addWatched, returned content missing id or title!", "id", id, "title", title)
return Watched{}, errors.New("content response missing id or title")
}
content = Content{
@@ -162,7 +189,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
Overview: overview,
PosterPath: posterPath,
Type: ar.ContentType,
ReleaseDate: releaseDate,
ReleaseDate: &releaseDate,
Popularity: popularity,
VoteAverage: voteAverage,
VoteCount: voteCount,
@@ -178,7 +205,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
if res.Error != nil {
// Error if anything but unique contraint error
if !strings.Contains(res.Error.Error(), "UNIQUE") {
println("Error creating content in database:", res.Error.Error())
slog.Error("Error creating content in database", "error", res.Error.Error())
return Watched{}, errors.New("failed to cache content in database")
}
}
@@ -186,7 +213,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
if res.RowsAffected > 0 {
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
if err != nil {
println("Failed to download content image!", err.Error())
slog.Error("Failed to download content image!", "error", err.Error())
}
}
}
@@ -209,28 +236,29 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
if watched.DeletedAt.Time.IsZero() {
return Watched{}, errors.New("content already on watched list")
} else {
println("Watched list item exists as soft deleted record.. attempting to restore")
slog.Info("addWatched: Watched list item for this content exists as soft deleted record.. attempting to restore")
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
watched.Status = ar.Status
watched.Rating = ar.Rating
if res.Error != nil {
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
}
}
} else {
println("Error adding watched content to database:", res.Error.Error())
slog.Error("Error adding watched content to database", "error", res.Error.Error())
return Watched{}, errors.New("failed adding content to database")
}
}
fmt.Printf("%+v\n", watched)
slog.Debug("Added watched list item", "item", watched)
var activity Activity
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
if err != nil {
println("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", err.Error())
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: ADDED_WATCHED})
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
} else {
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: ADDED_WATCHED, Data: string(activityJson)})
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
}
watched.Activity = append(watched.Activity, activity)
watched.Content = content
@@ -239,11 +267,11 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
// this method is too ugly to look at please make him look better, future irhm
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (WatchedUpdateResponse, error) {
println("UpdateWatched", ar.Rating, ar.Status)
slog.Debug("UpdateWatched", "request_data", ar)
upwat := Watched{}
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
if res.Error != nil {
println("Watched entry update failed:", id, res.Error.Error())
slog.Error("Watched entry update failed:", "id", id, "error", res.Error.Error())
return WatchedUpdateResponse{}, errors.New("failed to update watched entry")
}
originalThoughts := upwat.Thoughts
@@ -280,14 +308,14 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
}
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
println("Removing watched item:", id, "for user", userId)
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.
// Since other tables (eg activities) will link their rows to a watched_id, it's best to soft
// delete, so if user restores watched item they still have activity for example (also so
// someone else wont get other users activity if auto increment gives them the same watched id).
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Delete(&Watched{})
if res.Error != nil {
println("Removing watched entry failed:", id, res.Error.Error())
slog.Error("Removing watched entry failed", "id", id, "error", res.Error.Error())
return WatchedRemoveResponse{}, errors.New("failed to remove watched entry")
}
if res.RowsAffected <= 0 {
@@ -298,7 +326,7 @@ func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, er
}
func download(url string, outf string) (err error) {
println("Attempting to download file from", url, "to", outf)
slog.Debug("Attempting to download file", "url", url, "outf", outf)
// Create the file
out, err := os.Create(outf)
+6 -6
View File
@@ -1,12 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
+10 -10
View File
@@ -1,12 +1,12 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+12 -2
View File
@@ -36,6 +36,8 @@
return "thoughts changed";
case "THOUGHTS_REMOVED":
return "thoughts removed";
case "IMPORTED_WATCHED":
return "imported";
default:
return a.type;
}
@@ -106,7 +108,11 @@
position: sticky;
top: 0;
font-size: 16px;
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
padding-bottom: 1px;
background-color: $bg-color;
}
@@ -147,7 +153,11 @@
}
h2 {
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 30px;
font-weight: bold;
margin-left: 30px;
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
export let name: string;
export let value: boolean = false;
export let toggled: (on: boolean) => void;
export let disabled = false;
let actualDisabled = false;
$: {
// In cases where we disable and enable the checkbox super fast
// it ends up looking like a jittery mess. To circumvent this
// issue, we add a small delay before undisabling.
// (eg. fast internet and net request dis and re enables it quickly)
if (disabled) {
actualDisabled = true;
} else {
setTimeout(() => {
actualDisabled = disabled;
}, 150);
}
}
function checkboxChange(e: Event) {
toggled((e.target as HTMLInputElement).checked);
}
</script>
<div class="toggle-pill-color">
<input
bind:checked={value}
disabled={actualDisabled}
type="checkbox"
id={name}
on:change={checkboxChange}
/>
<label for={name}></label>
</div>
<style lang="scss">
.toggle-pill-color {
width: min-content;
input {
display: none;
&:checked + label {
background: $success;
&::before {
left: 1.6em;
}
}
&:disabled + label {
opacity: 0.5;
cursor: not-allowed;
}
}
label {
display: block;
position: relative;
width: 3em;
height: 1.6em;
border-radius: 1em;
background: $error;
cursor: pointer;
user-select: none;
transition:
opacity 100ms ease-in-out,
opacity 100ms ease-in-out;
&::before {
content: "";
display: block;
width: 1.2em;
height: 1.2em;
border-radius: 1em;
background: #fff;
position: absolute;
left: 0.2em;
top: 0.2em;
transition: all 0.2s ease-in-out;
}
}
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import Icon from "./Icon.svelte";
export let options: string[];
export let active: string | undefined;
export let placeholder: string;
export let blendIn: boolean = false;
export let disabled = false;
let availableOptions = options.filter((o) => o !== active);
let open = false;
</script>
<div
class={[
open ? "is-open" : "",
!active ? "placeholder-shown" : "",
blendIn ? "blend-in" : ""
].join(" ")}
>
<button on:click={() => (open = !open)} {disabled}>
{active ? active : placeholder}
<Icon i="chevron" facing={open ? "up" : "down"} />
</button>
<ul>
{#each availableOptions as o}
<li>
<button
class="plain"
on:click={() => {
active = o;
open = false;
}}
>
{o}
</button>
</li>
{/each}
</ul>
</div>
<style lang="scss">
div {
position: relative;
&.placeholder-shown {
& > button {
color: #8e8e8e;
&:hover:not(:disabled) {
color: $bg-color;
}
}
}
&.blend-in {
& > button {
border-color: transparent;
background-color: transparent;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background-color: $text-color;
}
}
}
&.is-open {
ul {
display: flex;
}
& > button {
border-color: $text-color;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
}
button {
gap: 3px;
text-transform: capitalize;
:global(svg) {
margin-left: auto;
}
}
ul {
display: none;
flex-flow: column;
position: absolute;
list-style: none;
width: 100%;
font-size: 14px;
border: 2px solid $text-color;
border-top: 0;
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
background-color: $bg-color;
z-index: 99;
li {
width: 100%;
button {
padding: 5px 10px;
width: 100%;
text-align: start;
text-transform: capitalize;
transition: background-color 100ms ease;
&:hover:not(:disabled),
&:focus-visible:not(:disabled) {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
}
}
}
}
}
</style>
+20 -7
View File
@@ -2,19 +2,32 @@
export let title: string | undefined;
</script>
{#if title}
<h2>{title}</h2>
{/if}
<ul>
<slot />
</ul>
<div>
{#if title}
<h2>{title}</h2>
{/if}
<ul>
<slot />
</ul>
</div>
<style lang="scss">
div {
width: 100%;
overflow-x: auto;
}
h2 {
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 30px;
font-weight: bold;
margin-left: 30px;
position: sticky;
left: 0;
}
ul {
+143 -3
View File
@@ -2,11 +2,12 @@
import type { Icon } from "@/types";
export let i: Icon;
export let wh = 24;
export let wh: number | string = 24;
export let facing: "up" | "down" | "left" | "right" | undefined = undefined;
</script>
{#if i === "check"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
<path
fill="none"
stroke="currentColor"
@@ -84,7 +85,7 @@
/>
</svg>
{:else if i === "close"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
<path
fill="none"
stroke="currentColor"
@@ -100,4 +101,143 @@
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 === "reel"}
<svg
id="reel"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
fill="none"
>
<path
fill="#fff"
d="M426 138c-1-37-2-74-6-109-14-7-33-12-54-9-29 9-49 31-80 36-50 9-80-25-116-37-7-2-20 1-25-2l-3 2c-21 6-42 11-51 29-4 74-3 149-3 224 0 74-4 150 6 220 23 9 54 0 79-2 25-7 54-10 82-16 25-9 45-24 75-28 11 0 22 0 31 2 21 8 31 35 60 29l-1-111c-3-78 7-153 6-228Z"
/>
<path
fill="#000"
d="M158 133c19-2 55 3 75-2-19-8-49-1-72-4 0-16 5-39-3-51-5 13-3 42 0 57Zm-1-72c3-1 4-2 4-7-6-6-12 7-4 7Zm-8-34 19 1c36 15 61 41 114 38 33-5 54-22 80-34l-1 104c-70 4-143 1-213 4l1-111v-2Zm-15 21c2 4 1 11 0 15-8 1-20 3-28 2-2-4-1-11-1-15 8-2 21-4 29-2Zm268 6c3 3 1 10 2 15-7 3-21 2-29 1-2-4 0-11-1-16 7-4 21-2 28 0ZM133 80v20c-10 0-20-1-29-3l1-17c9-2 18 2 28 0Zm271 8c3 4 2 14 1 18-8-1-22 2-31-1l1-16c9-3 20 2 29-1Zm-300 28c9 2 20 1 31 1v20c-9 2-20-1-30-1 0-7-2-12-1-20m271 5 32 1c-1 6 1 14-1 19-11-1-22 2-32 0 1-7-1-16 1-20Zm-270 34c11-1 19 1 30 1v19c-9 3-21 0-30-1v-19Zm206 139c4 0 12 3 13-1-1-3-14-5-13 1Zm-144-1c5 7 23 1 27-5-9-2-20 2-27 5Zm-12-10c6-12 3-30 4-46 1-22 2-45-1-65l-3-1c-2 35-5 78 0 112Zm66-119 1 1c37 2 78 4 116 4 5 0 13 3 15-3-42-4-90-2-134-4l-1 1c1 0 1 2 3 1Zm-73-8c68 3 145 4 213 0-1 48-5 94-5 142-65 6-141 1-207 5l-2-145 1-2Zm259 6c1 5 0 12-1 17-12-1-24 4-33 0v-18c9-3 25-1 34 1Zm-2 35c0 6 1 14-1 19-11-1-24 0-31 1-3-4-3-16-1-20 7-3 23-1 33 0Zm-303 2c10-2 23 0 34 0l-2 20c-11 0-21-2-31-1 0-6 1-14-1-19Zm303 35-1 20c-9 2-22 1-32 1v-22c10-1 23 1 33 1Zm-302 0c11 2 26-2 33 3 0 7-2 12-1 20-11 0-22 0-32-2v-21Zm302 41c1 5 2 12 0 17-10 3-24 1-36 2v-22c12 1 28-2 36 3Zm-268 2-1 21c-10 2-24-2-34 0 1-7-1-16 1-21h34Zm268 37c2 5-1 11 0 18-10 2-25 0-35 2-2-5 0-14-2-19 9-3 27-2 37-1Zm-268 1v17c-9 3-25 2-35 1v-17c9-2 22-1 35-1Zm20 15c9 2 23 2 30-1-7-3-25-4-30 1Zm59-4c31 9 84 3 123 7 5 17 2 41 3 63-1 4-5 16 1 19 4-23 6-62 3-87-39-3-87-3-130-2Zm139-11c-1 39-4 83 0 121-23-6-40 5-61 7-45 4-97 2-143 6-2-43-1-87-2-130 68 2 137-4 206-4Zm49 34c0 6 2 11 1 17-7 4-21 1-32 2v-18c9-1 21-3 31-1m-270 0c1 8-1 13-1 21-10 0-23 1-30-3v-17c10-3 23 1 31-1m272 35v16c-9 2-21 0-32 0 0-5 1-11-1-16 9-1 24-2 33 0Zm-269 3v21c-10-3-28 1-35-4v-15c9-4 24-3 35-2Zm270 31v16c-8 3-24 2-32 0 0-6-1-11 1-16 9-2 23-2 31 0Zm-270 7 2 19c-10 3-25 0-35 2 0-6-2-15-1-21 11 0 24-2 34 0Zm119 36c-33 7-67 13-103 18 1-7-1-11-1-17 36 1 70 0 104-1Zm-119 1v20c-11 0-23 1-32-2v-17l32-1Z"
/>
</svg>
{:else if i === "compass"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
fill="none"
stroke="currentColor"
stroke-miterlimit="10"
stroke-width="32"
d="M448 256a192 192 0 1 0-384 0 192 192 0 0 0 384 0z"
/>
<path
d="m351 151-118 47a64 64 0 0 0-35 35l-47 118a8 8 0 0 0 10 10l118-47a64 64 0 0 0 35-35l47-118a8 8 0 0 0-10-10zm-95 129a24 24 0 1 1 24-24 24 24 0 0 1-24 24z"
/>
</svg>
{:else if i === "document"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path
d="M416 221.25V416a48 48 0 0 1-48 48H144a48 48 0 0 1-48-48V96a48 48 0 0 1 48-48h98.75a32 32 0 0 1 22.62 9.37l141.26 141.26a32 32 0 0 1 9.37 22.62z"
fill="none"
stroke="currentColor"
stroke-linejoin="round"
stroke-width="32"
/>
<path
d="M256 56v120a32 32 0 0 0 32 32h120m-232 80h160m-160 80h160"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32"
/>
</svg>
{:else if i === "add"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="32"
d="M256 112v288M400 256H112"
/>
</svg>
{:else if i === "arrow"}
<svg
class={facing}
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 512 512"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="48"
d="M244 400L100 256l144-144M120 256h292"
/>
</svg>
{:else if i === "chevron"}
<svg
class={facing}
xmlns="http://www.w3.org/2000/svg"
width={wh}
height={wh}
viewBox="0 0 512 512"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="48"
d="M328 112L184 256l144 144"
/>
</svg>
{:else if i === "search"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"
/>
</svg>
{:else if i === "sort"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} 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"
/>
</svg>
{:else if i === "eye-closed"}
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
<path
d="M432 448a15.92 15.92 0 01-11.31-4.69l-352-352a16 16 0 0122.62-22.62l352 352A16 16 0 01432 448zM248 315.85l-51.79-51.79a2 2 0 00-3.39 1.69 64.11 64.11 0 0053.49 53.49 2 2 0 001.69-3.39zM264 196.15L315.87 248a2 2 0 003.4-1.69 64.13 64.13 0 00-53.55-53.55 2 2 0 00-1.72 3.39z"
/>
<path
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>
{/if}
<style lang="scss">
svg {
transition: transform 100ms ease-in-out;
&.up {
transform: rotate(90deg);
}
&.down {
transform: rotate(270deg);
}
}
#reel {
path:first-of-type {
fill: $text-color;
}
path:last-of-type {
fill: $bg-color;
}
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import Icon from "./Icon.svelte";
export let title: string;
export let desc: string;
export let onClose: (() => void) | undefined = undefined;
</script>
<div class="backdrop"></div>
<div class="modal">
<div>
{#if typeof onClose !== "undefined"}
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
{/if}
<h3 class="norm">{title}</h3>
<h5 class="norm">{desc}</h5>
<slot />
</div>
</div>
<style lang="scss">
.backdrop {
position: absolute;
top: 0;
left: 0;
width: 100dvw;
height: 100dvh;
z-index: 9998;
backdrop-filter: blur(2px) saturate(180%);
background-color: color-mix(in srgb, black 85%, transparent);
position: fixed;
}
.modal {
display: flex;
justify-content: center;
align-items: center;
width: 100dvw;
height: 100dvh;
top: 0;
left: 0;
position: fixed;
z-index: 9999;
& > div {
position: relative;
min-width: 300px;
min-height: 300px;
width: 100%;
max-width: 1000px;
background-color: $bg-color;
border-radius: 10px;
padding: 15px 20px;
margin: 20px 100px;
transition: margin 100ms ease;
button.close {
position: absolute;
top: 8px;
right: 8px;
width: max-content;
padding: 3px 5px;
}
@media screen and (max-width: 680px) {
margin: 20px;
}
}
}
</style>
+9 -3
View File
@@ -65,7 +65,9 @@
height: 100%;
min-height: 256.367px;
position: relative;
transition: transform 150ms ease, outline 50ms ease;
transition:
transform 150ms ease,
outline 50ms ease;
cursor: pointer;
img {
@@ -112,7 +114,11 @@
h2,
h3 {
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
@@ -144,7 +150,7 @@
&:not(:not(.no-zoom)) {
&:hover,
&:focus-within {
outline: 3px solid black;
outline: 3px solid $text-color;
}
}
+175 -84
View File
@@ -9,20 +9,28 @@
} from "@/lib/util/helpers";
import { goto } from "$app/navigation";
import tooltip from "./actions/tooltip";
import { removeWatched, updateWatched } from "./util/api";
import { notify } from "./util/notify";
import { onMount } from "svelte";
export let id: number | undefined = undefined; // Watched list id
export let media: {
poster_path?: string;
title?: string;
name?: string;
overview?: string;
id?: number;
media_type?: MediaType;
id: number; // tmdb id
media_type: MediaType;
release_date?: string;
first_air_date?: string;
};
export let rating: number | undefined = undefined;
export let status: WatchedStatus | undefined = undefined;
export let onStatusChanged: (type: WatchedStatus) => void = () => {};
export let onRatingChanged: (rating: number) => void = () => {};
export let onDeleteClicked: () => void = () => {};
export let small = false;
export let disableInteraction = false;
export let hideButtons = false;
// When provided, default click handlers will instead run this callback.
export let onClick: (() => void) | undefined = undefined;
// If poster is active (scaled up)
let posterActive = false;
@@ -31,25 +39,54 @@
// If statuses are shown
let statusesShown = false;
let containerEl: HTMLDivElement;
const title = media.title || media.name;
const poster = `https://image.tmdb.org/t/p/w500${media.poster_path}`;
const link = media.id ? `/${media.media_type}/${media.id}` : undefined;
const dateStr = media.release_date || media.first_air_date;
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
function handleStarClick(r: number) {
if (r == rating) return;
onRatingChanged(r);
updateWatched(media.id, media.media_type, undefined, r);
ratingsShown = false;
}
function handleStatusClick(type: WatchedStatus | "DELETE") {
if (type === "DELETE") {
onDeleteClicked();
if (!id) {
notify({ text: "Content has no watched list id, can't delete.", type: "error" });
return;
}
removeWatched(id);
return;
}
onStatusChanged(type);
updateWatched(media.id, media.media_type, type);
}
function handleInnerKeyUp(e: KeyboardEvent) {
console.log(e.target);
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
if (typeof onClick !== "undefined") {
onClick();
return;
}
if (link) {
goto(link);
}
}
}
onMount(() => {
if (small && containerEl) {
containerEl.classList.add("small");
}
});
</script>
<!-- HACK: disabled this issue for now, it should probably be fixed properly -->
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
<li
on:mouseenter={(e) => {
if (!posterActive) calculateTransformOrigin(e);
@@ -63,9 +100,9 @@
on:mouseleave={() => (posterActive = false)}
on:click={() => (posterActive = true)}
on:keypress={() => console.log("on kpress")}
class={posterActive ? "active" : ""}
class={`${posterActive ? "active " : ""}${disableInteraction ? "interaction-disabled" : ""}`}
>
<div class={`container${!poster ? " details-shown" : ""}`}>
<div class={`container${!poster ? " details-shown" : ""}`} bind:this={containerEl}>
{#if poster}
<div class="img-loader" />
<img
@@ -82,92 +119,106 @@
{/if}
<div
on:click={() => {
if (typeof onClick !== "undefined") {
onClick();
return;
}
if (posterActive && link) goto(link);
}}
on:keypress={() => console.log("on kpress")}
on:keyup={handleInnerKeyUp}
id="ilikemoviessueme"
class="inner"
role="button"
tabindex="0"
>
<h2>
{#if link}
{#if typeof onClick === "undefined" && link}
<a data-sveltekit-preload-data="tap" href={link}>
{title}
</a>
{:else}
{title}
{/if}
{#if year}
<time>{year}</time>
{/if}
</h2>
<span>{media.overview}</span>
<div class="buttons">
<button
class="rating"
on:click={(ev) => {
ev.stopPropagation();
ratingsShown = !ratingsShown;
}}
on:mouseleave={(ev) => {
ratingsShown = false;
ev.currentTarget.blur();
}}
>
<span>*</span>
<span>{rating ? rating : "Rate"}</span>
{#if ratingsShown}
<div>
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
<button
class="plain{rating === v ? ' active' : ''}"
on:click={(ev) => {
ev.stopPropagation();
handleStarClick(v);
}}
>
{v}
</button>
{/each}
</div>
{/if}
</button>
<button
class="status"
on:click={(ev) => {
ev.stopPropagation();
statusesShown = !statusesShown;
}}
on:mouseleave={(ev) => {
statusesShown = false;
ev.currentTarget.blur();
}}
>
{#if status}
<Icon i={watchedStatuses[status]} />
{:else}
<span class="no-icon">+</span>
{/if}
{#if statusesShown}
<div>
{#each Object.entries(watchedStatuses) as [statusName, icon]}
<button
class="plain{status && status !== statusName ? ' not-active' : ''}"
on:click={() => handleStatusClick(statusName)}
use:tooltip={{ text: statusName }}
>
<Icon i={icon} />
</button>
{/each}
{#if status}
<button
class="plain not-active"
on:click={() => handleStatusClick("DELETE")}
use:tooltip={{ text: "Delete" }}
>
<Icon i="trash" />
</button>
{/if}
</div>
{/if}
</button>
</div>
{#if !hideButtons}
<div class="buttons">
<button
class="rating"
on:click={(ev) => {
ev.stopPropagation();
ratingsShown = !ratingsShown;
}}
on:mouseleave={(ev) => {
ratingsShown = false;
ev.currentTarget.blur();
}}
>
<span>*</span>
<span class={!rating && disableInteraction ? "unrated-text" : ""}>
{rating ? rating : disableInteraction ? "Unrated" : "Rate"}
</span>
{#if ratingsShown}
<div class="small-scrollbar">
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
<button
class="plain{rating === v ? ' active' : ''}"
on:click={(ev) => {
ev.stopPropagation();
handleStarClick(v);
}}
>
{v}
</button>
{/each}
</div>
{/if}
</button>
<button
class="status"
on:click={(ev) => {
ev.stopPropagation();
statusesShown = !statusesShown;
}}
on:mouseleave={(ev) => {
statusesShown = false;
ev.currentTarget.blur();
}}
>
{#if status}
<Icon i={watchedStatuses[status]} />
{:else}
<span class="no-icon">+</span>
{/if}
{#if statusesShown}
<div class="small-scrollbar">
{#each Object.entries(watchedStatuses) as [statusName, icon]}
<button
class="plain{status && status !== statusName ? ' not-active' : ''}"
on:click={() => handleStatusClick(statusName)}
use:tooltip={{ text: statusName }}
>
<Icon i={icon} />
</button>
{/each}
{#if status}
<button
class="plain not-active"
on:click={() => handleStatusClick("DELETE")}
use:tooltip={{ text: "Delete" }}
>
<Icon i="trash" />
</button>
{/if}
</div>
{/if}
</button>
</div>
{/if}
</div>
</div>
</li>
@@ -177,6 +228,27 @@
cursor: pointer;
}
li.interaction-disabled {
button {
pointer-events: none;
cursor: default;
background-color: transparent;
border: unset;
fill: white;
color: white;
span {
color: white !important;
}
.unrated-text {
display: flex;
align-items: center;
font-size: 15px !important;
}
}
}
.container {
display: flex;
flex-flow: column;
@@ -210,7 +282,7 @@
background-size: 400% 400%;
animation: imgloader 4s ease infinite;
@-webkit-keyframes imgloader {
@keyframes imgloader {
0% {
background-position: 50% 0%;
}
@@ -236,7 +308,11 @@
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
h2 {
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
color: white;
word-wrap: break-word;
@@ -244,6 +320,12 @@
a {
color: white;
}
time {
font-size: 14px;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
}
}
span {
@@ -355,12 +437,21 @@
}
}
&.small .inner span {
font-size: 11px;
}
&:hover,
&:focus-within {
transform: scale(1.3);
z-index: 99;
}
&.small:hover,
&.small:focus-within {
transform: scale(1.1);
}
&:hover,
&:focus-within,
&:global(.details-shown) {
+23 -1
View File
@@ -1,5 +1,19 @@
<script lang="ts">
import { onMount } from "svelte";
export let type: "wrapped" | "vertical" = "wrapped";
let ulEl: HTMLUListElement;
onMount(() => {
if (ulEl) {
ulEl.classList.add(type);
}
});
</script>
<div>
<ul>
<ul bind:this={ulEl}>
<slot />
</ul>
</div>
@@ -19,5 +33,13 @@
flex-wrap: wrap;
margin: 20px 10px;
max-width: 1200px;
&:global(.vertical) {
flex-wrap: nowrap;
justify-content: unset;
overflow-x: auto;
padding: 15px 8px;
margin: 5px 0;
}
}
</style>
+11 -95
View File
@@ -3,13 +3,13 @@
import axios from "axios";
import Spinner from "./Spinner.svelte";
import Error from "./Error.svelte";
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
export let tvId: number;
export let seasons: TMDBShowSeason[];
let activeSeason = 1;
let seasonDetailsReq = sdr(activeSeason);
let seasonsEl: HTMLUListElement, episodesEl: HTMLDivElement;
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
async function sdr(seasonNum: number) {
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).data as TMDBSeasonDetails;
@@ -18,18 +18,10 @@
$: {
seasonDetailsReq = sdr(activeSeason);
}
// onMount(() => {
// episodesEl.style.height = `${seasonsEl.clientHeight}px`;
// seasonsEl.addEventListener("resize", () => {
// console.log("resize");
// episodesEl.style.height = `${seasonsEl.clientHeight}px`;
// });
// });
</script>
<div class="ctr">
<ul class="seasons" bind:this={seasonsEl}>
<ul class="seasons">
<!-- {#each seasons.sort((a, b) => Date.parse(b.air_date) - Date.parse(a.air_date)) as season} -->
{#each seasons as season}
<button
@@ -41,42 +33,26 @@
<h1>{season.name}</h1>
{#if season.air_date}
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
{:else if season.season_number > 0}
<h2>TBD</h2>
{/if}
</button>
{/each}
<div class="last" />
</ul>
<div class="episodes" bind:this={episodesEl}>
<div class="episodes">
{#await seasonDetailsReq}
<Spinner />
{:then season}
{#if season}
{#if season?.episodes?.length > 0}
<ul>
{#each season.episodes as ep}
<li>
{#if ep.still_path}
<img
src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`}
alt=""
/>
{:else}
<div class="no-still" />
{/if}
<span>
<b>{ep.episode_number}</b>
{ep.name}
</span>
<span
class="rating"
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
>
<span>*</span>
{Math.round(ep.vote_average * 10) / 10}
</span>
</li>
<SeasonsListEpisode {ep} />
{/each}
</ul>
{:else}
<h3 class="norm">No episodes in this season yet!</h3>
{/if}
{:catch err}
<Error pretty="Failed to load season details!" error={err} />
@@ -101,67 +77,6 @@
list-style: none;
gap: 20px;
}
li {
display: flex;
flex-flow: row;
gap: 8px;
img,
.no-still {
width: 227px;
min-width: 227px;
height: 127px;
min-height: 127px;
border-radius: 10px;
background-color: rgb(0, 0, 0);
object-fit: fill;
@media screen and (max-width: 590px) {
width: 80%;
height: auto;
}
@media screen and (max-width: 450px) {
width: 100%;
}
}
span {
padding: 3px 5px;
@media screen and (max-width: 590px) {
text-align: center;
}
}
.rating {
display: flex;
align-items: start;
justify-content: center;
font-size: 15px;
color: $rating-color;
font-weight: bolder;
overflow: hidden;
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px $rating-color;
font-size: 25px;
line-height: 0.7;
margin-top: 1.5px;
}
}
}
@media screen and (max-width: 590px) {
li {
align-items: center;
flex-flow: column;
width: 100%;
height: 100%;
}
}
}
ul.seasons {
@@ -193,6 +108,7 @@
h1 {
font-size: 18px;
font-family: sans-serif;
max-width: 150px;
}
h2 {
+144
View File
@@ -0,0 +1,144 @@
<script lang="ts">
import type { TMDBSeasonDetailsEpisode } from "@/types";
import Icon from "./Icon.svelte";
import { userSettings } from "@/store";
$: settings = $userSettings;
export let ep: TMDBSeasonDetailsEpisode;
let isHidden = false;
$: {
if (settings) isHidden = settings.hideSpoilers;
}
</script>
<li class={isHidden ? "dont-spoil" : ""}>
{#if ep.still_path}
<img src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`} alt="" />
{:else}
<div class="no-still" />
{/if}
<span>
<b>{ep.episode_number}</b>
<span class="episode-name">{ep.name}</span>
</span>
<span
class="rating"
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
>
<span>*</span>
{Math.round(ep.vote_average * 10) / 10}
</span>
{#if isHidden}
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
<Icon i="eye-closed" wh={34} />
<span>Click To Reveal</span>
</button>
{/if}
</li>
<style lang="scss">
li {
display: flex;
flex-flow: row;
gap: 8px;
position: relative;
img,
.no-still {
width: 227px;
min-width: 227px;
height: 127px;
min-height: 127px;
border-radius: 10px;
background-color: rgb(0, 0, 0);
object-fit: fill;
@media screen and (max-width: 590px) {
width: 80%;
height: auto;
}
@media screen and (max-width: 450px) {
width: 100%;
}
}
span {
padding: 3px 5px;
@media screen and (max-width: 590px) {
text-align: center;
}
}
.rating {
display: flex;
align-items: start;
justify-content: center;
font-size: 15px;
color: $rating-color;
font-weight: bolder;
overflow: hidden;
span {
font-family: "Rampart One";
-webkit-text-stroke: 1px $rating-color;
font-size: 25px;
line-height: 0.7;
margin-top: 1.5px;
}
}
.spoiler-text {
display: flex;
flex-flow: column;
align-items: center;
justify-content: center;
gap: 8px;
position: absolute;
width: 100%;
height: 100%;
font-weight: bolder;
font-size: 20px;
fill: $text-color;
opacity: 0;
transition:
visibility 150ms ease-in,
opacity 150ms ease-in;
cursor: pointer;
&:hover,
&:active,
&:focus {
opacity: 1;
}
}
.episode-name,
img {
transition: filter 150ms ease-out;
}
&.dont-spoil {
.episode-name {
filter: blur(4px);
}
img {
filter: blur(6px);
}
}
}
@media screen and (max-width: 590px) {
li {
align-items: center;
flex-flow: column;
width: 100%;
height: 100%;
}
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script lang="ts">
export let style: string = "";
</script>
<div class="loader" {style}></div>
<style lang="scss">
.loader {
width: 16px;
height: 13px;
border: 2px solid #000;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
+49
View File
@@ -0,0 +1,49 @@
<script lang="ts">
import type { PublicUser } from "@/types";
export let users: PublicUser[];
</script>
<ul>
{#each users as user}
<li title={user.username}><a href="/lists/{user.id}/{user.username}">{user.username}</a></li>
{/each}
</ul>
<style lang="scss">
ul {
display: flex;
flex-flow: row;
flex-wrap: wrap;
gap: 10px;
margin: 5px 20px;
list-style: none;
justify-content: center;
li {
display: flex;
min-width: 50px;
max-width: 200px;
a {
flex-shrink: 1;
width: 100%;
height: 100%;
padding: 3px 8px;
border-radius: 4px;
background-color: $accent-color;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
transition:
background-color 150ms ease,
color 150ms ease;
&:hover {
color: $bg-color;
background-color: $accent-color-hover;
}
}
}
}
</style>
+103
View File
@@ -0,0 +1,103 @@
<script lang="ts">
import { goto } from "$app/navigation";
import Icon from "@/lib/Icon.svelte";
import Poster from "@/lib/Poster.svelte";
import PosterList from "@/lib/PosterList.svelte";
import { activeFilters, activeSort } from "@/store";
import type { Watched } from "@/types";
export let list: Watched[];
export let isPublicList: boolean = false;
$: sort = $activeSort;
$: filters = $activeFilters;
$: 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") {
if (sort[1] === "UP") return a.content.title.localeCompare(b.content.title);
else if (sort[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
} 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);
})
.filter((w) => {
if (filters.status.length <= 0 && filters.type.length <= 0) return w;
if (filters.status.length > 0 && filters.type.length > 0) {
return (
filters.status.includes(w.status?.toLowerCase()) && filters.type.includes(w.content.type)
);
}
if (filters.type.length > 0) {
return filters.type.includes(w.content.type);
}
if (filters.status.length > 0) {
return filters.status.includes(w.status?.toLowerCase());
}
});
</script>
<PosterList>
{#if watched?.length > 0}
{#each watched as w (w.id)}
<Poster
id={w.id}
media={{
id: w.content.tmdbId,
poster_path: w.content.poster_path,
title: w.content.title,
overview: w.content.overview,
media_type: w.content.type,
release_date: w.content.release_date,
first_air_date: w.content.first_air_date
}}
rating={w.rating}
status={w.status}
disableInteraction={isPublicList}
/>
{/each}
{:else}
<div class="empty-list">
<Icon i="reel" wh={80} />
{#if isPublicList}
<h2 class="norm">This watched list is empty!</h2>
<h4 class="norm">Come back later to see if they have added anything.</h4>
{:else}
<h2 class="norm">Your watched list is empty!</h2>
<h4 class="norm">Try searching for something you would like to add.</h4>
<button on:click={() => goto("/import")}>Import</button>
{/if}
</div>
{/if}
</PosterList>
<style lang="scss">
.empty-list {
display: flex;
flex-flow: column;
gap: 5px;
align-items: center;
h2 {
margin-top: 10px;
}
h4 {
font-weight: normal;
}
button {
width: max-content;
padding-left: 20px;
padding-right: 20px;
margin-top: 15px;
}
}
</style>
+123
View File
@@ -0,0 +1,123 @@
<!--
Thank You Wikimedia
https://commons.wikimedia.org/wiki/Category:SVG_logos_of_video_streaming_services
-->
<script lang="ts">
export let i: string;
export let wh = 24;
</script>
{#if i === "Apple TV Plus"}
<a href="https://tv.apple.com/" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.046 17.091" width={wh} height={wh}>
<path
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
/>
</svg>
</a>
{:else if i === "Apple TV"}
<a href="https://tv.apple.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
/>
</svg>
</a>
{:else if i === "Amazon Prime Video"}
<a href="https://www.amazon.com/gp/video/storefront/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
/>
</svg>
</a>
{:else if i === "Disney Plus"}
<a href="https://disneyplus.com/" target="_blank">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1041 565" width={wh} height={wh}
><path
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
/>
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
<defs>
<radialGradient
id="a"
gradientUnits="userSpaceOnUse"
cx="942.5"
cy="279.9"
r="760.1"
fx="942.5"
fy="279.9"
>
<stop offset="0" stop-color="#021192" /><stop offset="0" stop-color="#021096" /><stop
offset=".1"
stop-color="#010cb4"
/>
<stop offset=".1" stop-color="#0008ce" /><stop offset=".1" stop-color="#0006d7" /><stop
offset=".1"
stop-color="#0004e1"
/>
<stop offset=".2" stop-color="#0001fa" /><stop offset=".2" stop-color="#0000fe" /><stop
offset=".2"
stop-color="#0f1eff"
/>
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
>
</defs>
<path
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
/>
</svg>
</a>
{:else if i === "Netflix"}
<a href="https://netflix.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
<path
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
/>
</svg>
</a>
{:else if i === "Sky Go"}
<a href="https://go.sky.com/" target="_blank">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
<path
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
/>
</svg>
</a>
{:else if i === "Paramount Plus"}
<a href="https://paramountplus.com" target="_blank">
<svg
xmlns="http://www.w3.org/2000/svg"
width="100"
viewBox="-161.6 95.4 1000 230.4"
xml:space="preserve"
>
<path
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
/>
</svg>
</a>
{:else}
<span>{i}</span>
{/if}
<style lang="scss">
a {
display: contents;
}
svg {
fill: white;
opacity: 0.7;
&:hover {
opacity: 1;
}
}
span {
font-size: 14px;
font-weight: bold;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import type { TMDBContentWatchProviders } from "@/types";
import ProviderIcon from "./ProviderIcon.svelte";
export let providers: TMDBContentWatchProviders;
</script>
{#if providers?.flatrate?.length > 0}
<div class="streaming-providers">
{#each providers.flatrate as provider}
<ProviderIcon i={provider.provider_name} wh={40} />
{/each}
</div>
{/if}
<style lang="scss">
.streaming-providers {
display: flex;
align-items: flex-end;
flex-wrap: wrap;
gap: 15px;
margin-top: auto;
padding-top: 10px;
& span {
margin-bottom: 2px;
}
}
</style>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
import HorizontalList from "../HorizontalList.svelte";
import Poster from "../Poster.svelte";
import { watchedList } from "@/store";
import { getWatchedDependedProps } from "@/lib/util/helpers";
$: wList = $watchedList;
export let type: ContentType;
export let similar: TMDBShowSimilar | TMDBMovieSimilar;
</script>
<HorizontalList title="Similar">
{#each similar.results as content}
<Poster
media={{ ...content, media_type: type }}
{...getWatchedDependedProps(content.id, type, wList)}
small={true}
/>
{/each}
</HorizontalList>
+28 -1
View File
@@ -1,9 +1,36 @@
<script lang="ts">
import { onMount } from "svelte";
export let embed: string;
export let closed: () => void;
let modalDiv: HTMLDivElement;
onMount(() => {
// For better experience on keyboard.
// If modal shown by btn click, the btn will still be
// focused, so clicking `esc` wont trigger until tab
// is clicked first... which I count as not intuitive.
if (modalDiv) {
modalDiv.focus();
}
});
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
closed();
}
}
</script>
<div class="modal" on:click={closed} on:keydown={closed}>
<div
bind:this={modalDiv}
class="modal"
on:click={closed}
on:keydown={handleKeyDown}
role="button"
tabindex="0"
>
{#if embed}
<div class="wrapper">
<iframe title="Video Embed" src={embed} frameborder="0" width="100%" height="100%" />
+76 -13
View File
@@ -1,10 +1,14 @@
import { watchedList } from "@/store";
import type {
MediaType,
Watched,
WatchedAddRequest,
WatchedStatus,
WatchedUpdateRequest
import { userInfo, userSettings, watchedList } from "@/store";
import {
UserType,
type JellyfinFoundContent,
type MediaType,
type Watched,
type WatchedAddRequest,
type WatchedStatus,
type WatchedUpdateRequest,
type WatchedUpdateResponse,
type UserSettings
} from "@/types";
import axios from "axios";
import { get } from "svelte/store";
@@ -34,6 +38,7 @@ export function updateWatched(
(w) => w.content.tmdbId === contentId && w.content.type === contentType
);
if (wEntry?.id) {
const nid = notify({ text: `Saving`, type: "loading" });
if (!status && !rating && typeof thoughts === "undefined") return;
const obj = {} as WatchedUpdateRequest;
if (status) obj.status = status;
@@ -41,7 +46,7 @@ export function updateWatched(
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
if (thoughts === "") obj.removeThoughts = true;
axios
.put(`/watched/${wEntry.id}`, obj)
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
.then((resp) => {
if (status) wEntry.status = status;
if (rating) wEntry.rating = rating;
@@ -52,17 +57,22 @@ export function updateWatched(
} else {
wEntry.activity = [resp.data.newActivity];
}
// We want to update the updatedAt field too (so
// change is reflected when filtering modified at)
// We can piggy back from this data for now.
wEntry.updatedAt = resp.data.newActivity.createdAt;
}
watchedList.update((w) => w);
notify({ text: `Saved!`, type: "success" });
notify({ id: nid, text: `Saved!`, type: "success" });
})
.catch((err) => {
console.error(err);
notify({ text: "Failed To Update!", type: "error" });
notify({ id: nid, text: "Failed To Update!", type: "error" });
});
return;
}
// Add new watched item
const nid = notify({ text: `Adding`, type: "loading" });
axios
.post("/watched", {
contentId,
@@ -74,11 +84,11 @@ export function updateWatched(
console.log("Added watched:", resp.data);
wList.push(resp.data as Watched);
watchedList.update(() => wList);
notify({ text: `Added!`, type: "success" });
notify({ id: nid, text: `Added!`, type: "success" });
})
.catch((err) => {
console.error(err);
notify({ text: "Failed To Add!", type: "error" });
notify({ id: nid, text: "Failed To Add!", type: "error" });
});
}
@@ -87,10 +97,12 @@ export function updateWatched(
* @param id Watched Entry ID
*/
export function removeWatched(id: number) {
const nid = notify({ text: `Removing`, type: "loading" });
const wList = get(watchedList);
const wEntry = wList.find((w) => w.id === id);
if (!wEntry) {
console.log("Watched entry does not exist!");
notify({ text: "Item Doesn't Exist On Watched List!", type: "error" });
return;
}
axios
@@ -99,10 +111,61 @@ export function removeWatched(id: number) {
console.log("Removed watched:", resp.data);
const newList = wList.filter((w) => w.id !== id);
watchedList.update(() => newList);
notify({ id: nid, text: "Removed!", type: "error" });
})
.catch((err) => {
console.error(err);
notify({ text: "Failed To Remove!", type: "error" });
notify({ id: nid, text: "Failed To Remove!", type: "error" });
});
}
export async function contentExistsOnJellyfin(
type: MediaType,
name: string,
tmdbId: number
): Promise<JellyfinFoundContent | undefined> {
try {
const user = get(userInfo);
if (Number(user?.type) == UserType.Jellyfin) {
const resp = await axios.get(`/jellyfin/${type}/${name}/${tmdbId}`);
console.log("contentExistsOnJellyfin response:", resp.data);
return resp.data as JellyfinFoundContent;
}
} catch (err) {
console.error(err);
// notify({ text: "Failed To Remove!", type: "error" });
}
}
export function updateUserSetting<K extends keyof UserSettings>(
name: K,
value: UserSettings[K],
done?: () => void
) {
console.log("Updating user setting", name, "to", value);
const uSettings = get(userSettings);
if (!uSettings) {
console.log("updateUserSetting: userSettings not set..");
return;
}
const originalValue = uSettings[name];
const nid = notify({ type: "loading", text: "Updating" });
axios
.post("/user/update", { [name]: value })
.then((r) => {
if (r.status === 200) {
uSettings[name] = value;
userSettings.update((u) => (u = uSettings));
notify({ id: nid, type: "success", text: "Updated" });
if (typeof done !== "undefined") done();
}
})
.catch((err) => {
console.error("Failed to update user setting", err);
notify({ id: nid, type: "error", text: "Couldn't Update" });
uSettings[name] = originalValue;
userSettings.update((u) => (u = uSettings));
if (typeof done !== "undefined") done();
});
}
+36 -7
View File
@@ -1,11 +1,13 @@
import { appTheme } from "@/store";
import type {
Icon,
MediaType,
TMDBContentCreditsCrew,
Theme,
Watched,
WatchedStatus
import {
UserPermission,
type Icon,
type MediaType,
type TMDBContentCreditsCrew,
type Theme,
type TokenClaims,
type Watched,
type WatchedStatus
} from "@/types";
export const watchedStatuses: {
@@ -59,6 +61,7 @@ export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Wat
if (!wel) return {};
console.log(wid, wtype, wel?.content.title, wel?.status, wel?.rating);
return {
id: wel.id,
status: wel.status,
rating: wel.rating
};
@@ -159,3 +162,29 @@ export function toggleTheme(theme: Theme) {
appTheme.update((t) => (t = "light"));
}
}
export function parseTokenPayload(): TokenClaims | undefined {
try {
const token = localStorage.getItem("token");
if (!token) return;
return JSON.parse(atob(token.split(".")[1])) as TokenClaims;
} catch (err) {
return;
}
}
export async function sleep(ms: number) {
return new Promise<void>((r) =>
setTimeout(() => {
r();
}, ms)
);
}
export function userHasPermission(perms: UserPermission, reqPerm: UserPermission): boolean {
// Admins have permission for everything.
if (perms & UserPermission.PERM_ADMIN) {
return true;
}
return (perms & reqPerm) == reqPerm;
}
+25 -6
View File
@@ -2,6 +2,12 @@ import { notifications } from "@/store";
import { get } from "svelte/store";
export interface Notification {
/**
* Notification ID.
* Used to reference an exiting notification.
*/
id?: number;
/**
* Text shown in popup;
*/
@@ -9,8 +15,10 @@ export interface Notification {
/**
* Type of notification, controls the style.
* Loading notifs never hide, so after request completion
* or failure, the notif must be updated or `unNotify`ed.
*/
type?: "error" | "success";
type?: "error" | "success" | "loading";
/**
* How long in milliseconds the popup will stay shown for.
@@ -20,11 +28,22 @@ export interface Notification {
export function notify(n: Notification) {
const notifs = get(notifications);
const id = Math.random();
notifs.push({ id, ...n });
notifications.update(() => notifs);
setTimeout(() => unNotify(id), n.time ?? 2500);
return id;
if (n.id) {
const notif = notifs.find((not) => not.id === n.id);
if (notif) {
notif.type = n.type;
notif.text = n.text;
notifications.update(() => notifs);
} else {
console.error("Can't update notif that doesnt exist", n);
}
} else {
n.id = Math.random();
notifs.push({ ...n });
notifications.update(() => notifs);
}
if (n.type !== "loading") setTimeout(() => unNotify(n.id!), n.time ?? 2500);
return n.id;
}
export function unNotify(id: number) {
+74 -19
View File
@@ -2,7 +2,9 @@
@font-face {
font-family: "Shrikhand";
src: local("Shrikhand Regular"), local("Shrikhand-Regular"),
src:
local("Shrikhand Regular"),
local("Shrikhand-Regular"),
url("/Shrikhand-Regular.woff2") format("woff2");
font-weight: normal;
font-style: italic;
@@ -11,7 +13,9 @@
@font-face {
font-family: "Rampart One";
src: local("Rampart One Regular"), local("RampartOne-Regular"),
src:
local("Rampart One Regular"),
local("RampartOne-Regular"),
url("/RampartOne-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
@@ -25,13 +29,25 @@
}
:global(body) {
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
background-color: $bg-color;
color: $text-color;
}
:global(h1:not(.norm), h2:not(.norm), h3:not(.norm), h4:not(.norm), h5:not(.norm)) {
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
}
:global(.sm-caps) {
font-variant: small-caps;
}
:global(a) {
@@ -49,8 +65,8 @@
:global(textarea) {
padding: 5px;
max-width: 100%;
background-color: transparent;
color: $text-color;
background-color: transparent;
border-color: $text-color;
}
@@ -58,7 +74,8 @@
cursor: pointer;
}
:global(button:not(.plain)) {
:global(button:not(.plain)),
:global(a.btn) {
display: flex;
align-items: center;
justify-content: center;
@@ -70,19 +87,29 @@
fill: $text-color;
font-weight: bold;
width: 100%;
transition: background-color 100ms ease, opacity 100ms ease;
transition:
background-color 100ms ease,
opacity 100ms ease;
& :global(svg) {
height: 100%;
width: auto;
}
&:hover,
&:focus-visible {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
&:not(:disabled) {
&:hover,
&:focus-visible,
&.active {
background-color: $text-color;
color: $bg-color;
fill: $bg-color;
opacity: 1;
}
}
}
@@ -116,7 +143,9 @@
border: 1px solid white;
border-radius: 6px;
z-index: 99999;
transition: top 100ms ease, left 100ms ease;
transition:
top 100ms ease,
left 100ms ease;
pointer-events: none;
}
@@ -132,7 +161,7 @@
margin-bottom: 8px;
z-index: 99999;
div {
.notif {
display: flex;
flex-flow: row;
align-items: center;
@@ -145,6 +174,10 @@
animation: comein 250ms ease forwards;
position: relative;
&.loading {
padding-left: 10px;
}
@keyframes comein {
from {
opacity: 0;
@@ -157,8 +190,8 @@
&.error {
color: white;
background-color: #f3555a;
border: 1px solid #f3555a;
background-color: $error;
border: 1px solid $error;
span {
border-color: rgba($color: white, $alpha: 0.5);
@@ -167,8 +200,8 @@
&.success {
color: white;
background-color: #28a745;
border: 1px solid #28a745;
background-color: $success;
border: 1px solid $success;
span {
border-color: rgba($color: white, $alpha: 0.5);
@@ -181,6 +214,12 @@
padding-right: 12px;
border-right: 1px solid rgba($color: black, $alpha: 0.2);
padding: 10px 12px;
padding-left: 9px;
a {
color: white;
text-decoration: underline;
}
}
button {
@@ -193,4 +232,20 @@
}
}
}
.small-scrollbar {
&::-webkit-scrollbar {
width: 4.5px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background-color: rgba(155, 155, 155, 0.5);
border-radius: 20px;
border: transparent;
}
}
}
+368 -76
View File
@@ -4,18 +4,32 @@
import Icon from "@/lib/Icon.svelte";
import PageError from "@/lib/PageError.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { isTouch } from "@/lib/util/helpers";
import { activeFilter, clearAllStores, watchedList } from "@/store";
import { isTouch, parseTokenPayload, userHasPermission } from "@/lib/util/helpers";
import { notify } from "@/lib/util/notify";
import {
activeFilters,
activeSort,
clearAllStores,
searchQuery,
userInfo,
userSettings,
watchedList
} from "@/store";
import { type Filters, UserPermission } from "@/types";
import axios from "axios";
import { onMount } from "svelte";
import { get } from "svelte/store";
const username = localStorage.getItem("username");
let navEl: HTMLElement;
let searchTimeout: number;
let subMenuShown = false;
let filterMenuShown = false;
let sortMenuShown = false;
$: filter = $activeFilter;
$: sort = $activeSort;
$: filter = $activeFilters;
$: settings = $userSettings;
$: user = $userInfo;
function handleProfileClick() {
if (!localStorage.getItem("token")) {
@@ -23,14 +37,10 @@
} else {
subMenuShown = !subMenuShown;
filterMenuShown = false;
sortMenuShown = false;
}
}
function handleFilterClick() {
filterMenuShown = !filterMenuShown;
subMenuShown = false;
}
function handleSearch(ev: KeyboardEvent) {
if (
ev.key === "Tab" ||
@@ -49,8 +59,14 @@
const query = target?.value.trim();
if (!query) return;
if (query) {
// Enable autofocus before running `goto` because on chromium
// the .focus() call won't work, even after a timeout.
// Using autofocus seems to work. Disables after goto runs.
// https://github.com/sbondCo/Watcharr/issues/169
target.autofocus = true;
goto(`/search/${query}`).then(() => {
target?.focus();
target.autofocus = false;
});
}
},
@@ -69,19 +85,59 @@
subMenuShown = false;
}
async function getWatchedList() {
function serverSettings() {
goto("/server");
subMenuShown = false;
}
function shareWatchedList() {
const nid = notify({ type: "loading", text: "Getting link" });
const ud = parseTokenPayload();
console.log(ud);
if (ud?.userId && ud?.username) {
const shareLink = `${window.location.origin}/lists/${ud.userId}/${ud.username}`;
navigator.clipboard
.writeText(shareLink)
.then(() => {
notify({ id: nid, type: "success", text: "Copied share link" });
})
.catch((r) => {
console.error("Failed to copy list share link", r);
notify({
id: nid,
type: "error",
text: `Failed to copy share link:<br/><a href="${shareLink}" target="_blank">${shareLink}</a>`,
time: 20000
});
});
} else {
notify({ id: nid, type: "error", text: "Failed to get link" });
}
}
async function getInitialData() {
if (localStorage.getItem("token")) {
const w = await axios.get("/watched");
const [w, u, s] = await Promise.all([
axios.get("/watched"),
axios.get("/user"),
axios.get("/user/settings")
]);
if (w?.data?.length > 0) {
watchedList.update((wl) => (wl = w.data));
}
if (u?.data) {
userInfo.update((ui) => (ui = u.data));
}
if (s?.data) {
userSettings.update((us) => (us = s.data));
}
} else {
goto("/login?again=1");
}
}
function filterClicked(type: string, modeType: string = "UPDOWN") {
const af = get(activeFilter);
function sortClicked(type: string, modeType: string = "UPDOWN") {
const af = get(activeSort);
let mode: string;
if (modeType === "UPDOWN") {
mode = "UP";
@@ -103,46 +159,163 @@
console.error("filterClicked() ran without a valid modeType:", modeType);
return;
}
activeFilter.update((af) => (af = [type, mode]));
activeSort.update((af) => (af = [type, mode]));
}
function filterClicked(type: keyof Filters, f: string) {
const af = get(activeFilters);
if (af[type]?.includes(f)) {
af[type] = af[type]?.filter((a) => a !== f);
} else {
af[type]?.push(f);
}
activeFilters.update((a) => (a = af));
}
onMount(() => {
if (navEl) {
let scroll = window.scrollY;
window.document.addEventListener("scroll", (ev: Event) => {
if (scroll > window.scrollY) {
navEl?.classList.remove("scrolled-down");
} else {
navEl?.classList.add("scrolled-down");
subMenuShown = false;
filterMenuShown = false;
sortMenuShown = false;
}
scroll = window.scrollY;
});
} else {
console.error("navEl doesn't exist, failed to initialize up/down listener");
}
});
</script>
<nav>
<nav bind:this={navEl}>
<a href="/">
<span class="large">Watcharr</span>
<span class="small">W</span>
</a>
<input type="text" placeholder="Search" on:keydown={handleSearch} />
<input type="text" placeholder="Search" bind:value={$searchQuery} on:keydown={handleSearch} />
<div class="btns">
<!-- Only show on watched list -->
{#if $page.url?.pathname === "/"}
<button class="plain filter" on:click={handleFilterClick}>
<button
class="plain other filter"
on:click={() => {
filterMenuShown = !filterMenuShown;
sortMenuShown = false;
subMenuShown = false;
}}
>
<Icon i="filter" />
</button>
{#if filterMenuShown}
<div class="filter-menu">
<button
class="plain other sort"
on:click={() => {
sortMenuShown = !sortMenuShown;
filterMenuShown = false;
subMenuShown = false;
}}
>
<Icon i="sort" />
</button>
{#if sortMenuShown}
<div class="menu sort-menu">
<button
class={`plain ${filter[0] == "DATEADDED" ? filter[1].toLowerCase() : ""}`}
on:click={() => filterClicked("DATEADDED")}
class={`plain ${sort[0] == "DATEADDED" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("DATEADDED")}
>
Date Added
</button>
<button
class={`plain ${filter[0] == "ALPHA" ? filter[1].toLowerCase() : ""}`}
on:click={() => filterClicked("ALPHA")}
class={`plain ${sort[0] == "LASTCHANGED" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("LASTCHANGED")}
>
Last Changed
</button>
<button
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("RATING")}
>
Rating
</button>
<button
class={`plain ${sort[0] == "ALPHA" ? sort[1].toLowerCase() : ""}`}
on:click={() => sortClicked("ALPHA")}
>
Alphabetical
</button>
</div>
{/if}
{#if filterMenuShown}
<div class="menu filter-menu">
<h4 class="norm sm-caps">type</h4>
<div class="type-filter">
<button
class={`${filter.type.includes("tv") ? "active" : ""}`}
on:click={() => filterClicked("type", "tv")}
>
SHOW
</button>
<button
class={`${filter.type.includes("movie") ? "active" : ""}`}
on:click={() => filterClicked("type", "movie")}
>
MOVIE
</button>
</div>
<h4 class="norm sm-caps">status</h4>
<button
class={`plain ${filter.status.includes("planned") ? "on" : ""}`}
on:click={() => filterClicked("status", "planned")}
>
planned
</button>
<button
class={`plain ${filter.status.includes("watching") ? "on" : ""}`}
on:click={() => filterClicked("status", "watching")}
>
watching
</button>
<button
class={`plain ${filter.status.includes("finished") ? "on" : ""}`}
on:click={() => filterClicked("status", "finished")}
>
finished
</button>
<button
class={`plain ${filter.status.includes("hold") ? "on" : ""}`}
on:click={() => filterClicked("status", "hold")}
>
held
</button>
<button
class={`plain ${filter.status.includes("dropped") ? "on" : ""}`}
on:click={() => filterClicked("status", "dropped")}
>
dropped
</button>
</div>
{/if}
{/if}
<button class="plain other discover" on:click={() => goto("/discover")}>
<Icon i="compass" />
</button>
<button class="plain face" on:click={handleProfileClick}>:)</button>
{#if subMenuShown}
<div class="face-menu">
{#if username}
<h5 title={username}>Hi {username}!</h5>
<div class="menu face-menu">
{#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>
@@ -151,12 +324,12 @@
</div>
</nav>
{#await getWatchedList()}
{#await getInitialData()}
<Spinner />
{:then}
<slot />
{:catch err}
<PageError pretty="Failed to load watched list!" error={err} />
<PageError pretty="Failed to retrieve user data!" error={err} />
{/await}
<style lang="scss">
@@ -164,15 +337,32 @@
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 20px 28px 20px;
position: relative;
margin-bottom: 20px;
padding: 10px 20px;
position: sticky;
top: 0;
gap: 20px;
z-index: 999999999;
backdrop-filter: blur(2.5px) saturate(120%);
background-color: $nav-color;
transition: top 200ms ease-in-out;
&:global(.scrolled-down) {
top: -71px;
}
a {
text-decoration: none;
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 35px;
transition: -webkit-text-stroke 150ms ease, color 150ms ease, font-weight 150ms ease;
transition:
-webkit-text-stroke 150ms ease,
color 150ms ease,
font-weight 150ms ease;
&:hover,
&:focus-visible {
@@ -206,7 +396,9 @@
font-weight: bold;
text-align: center;
box-shadow: 4px 4px 0px 0px $text-color;
transition: width 150ms ease, box-shadow 150ms ease;
transition:
width 150ms ease,
box-shadow 150ms ease;
&:hover,
&:focus {
@@ -217,68 +409,82 @@
.btns {
display: flex;
flex-flow: row;
gap: 30px;
/* gap: 20px; */
button.filter {
button.other {
padding-top: 2px;
width: 28px;
transition: fill 150ms ease, stroke 150ms ease, stroke-width 150ms ease;
transition:
fill 150ms ease,
stroke 150ms ease,
stroke-width 150ms ease;
fill: $text-color;
&:hover,
&:focus-visible {
stroke: $bg-color;
stroke-width: 10px;
:global(path) {
fill: none;
stroke: $text-color;
stroke-width: 30px;
stroke-linejoin: round;
}
}
}
button.filter {
margin-right: 15px;
&:hover,
&:focus-visible {
:global(path) {
stroke-width: 15px;
}
}
}
button.sort {
margin-right: 12px;
}
button.discover {
margin-right: 17px;
transition:
fill 150ms ease,
stroke 150ms ease,
stroke-width 150ms ease,
transform 150ms ease;
&:hover,
&:focus-visible {
transform: rotate(60deg);
}
}
button.face {
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 25px;
transform: rotate(90deg);
cursor: pointer;
transition: -webkit-text-stroke 150ms ease, color 150ms ease;
margin-left: 3px;
transition:
-webkit-text-stroke 150ms ease,
color 150ms ease;
&:hover,
&:focus-visible {
color: white;
-webkit-text-stroke: 1.5px black;
color: $bg-color;
-webkit-text-stroke: 1.5px $text-color;
}
}
div.filter-menu {
width: 180px;
& > button {
position: relative;
&.down::before {
content: "\2193";
}
&.up::before {
content: "\2191";
}
&.on::before {
content: "\2713";
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family: system-ui, -apple-system, BlinkMacSystemFont;
font-size: 18px;
}
}
}
div {
div.menu {
display: flex;
flex-flow: column;
position: absolute;
right: 0;
right: 3px;
top: 55px;
width: 125px;
padding: 10px;
@@ -302,9 +508,10 @@
cursor: pointer;
transition: background-color 200ms ease;
&:hover {
background-color: rgba(0, 0, 0, 1);
color: white;
&:hover,
&:focus-visible {
background-color: $text-color;
color: $bg-color;
}
}
@@ -315,6 +522,91 @@
text-align: center;
}
}
div.sort-menu {
width: 180px;
& > button {
position: relative;
&.down::before {
content: "\2193";
}
&.up::before {
content: "\2191";
}
&.on::before {
content: "\2713";
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
}
div.filter-menu {
width: 180px;
right: 35px;
h4 {
margin-bottom: 8px;
&:not(:first-of-type) {
margin-top: 8px;
}
}
& > button {
text-transform: capitalize;
position: relative;
&.on::before {
content: "\2713";
}
&::before {
position: absolute;
top: 4px;
left: 12px;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 18px;
}
}
.type-filter {
display: flex;
flex-flow: row;
width: 100%;
button {
border-radius: 0;
padding: 8px 0;
width: 100%;
&:first-of-type {
border-right: 0;
border-radius: 5px 0 0 5px;
}
&:last-of-type {
border-radius: 0 5px 5px 0;
}
}
}
}
}
@media screen and (max-width: 580px) {
+3 -38
View File
@@ -1,45 +1,10 @@
<script lang="ts">
import Poster from "@/lib/Poster.svelte";
import PosterList from "@/lib/PosterList.svelte";
import { removeWatched, updateWatched } from "@/lib/util/api";
import { activeFilter, watchedList } from "@/store";
$: filter = $activeFilter;
$: watched = $watchedList.sort((a, b) => {
if (filter[0] === "DATEADDED" && filter[1] === "UP") {
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
} else if (filter[0] === "ALPHA") {
if (filter[1] === "UP") return a.content.title.localeCompare(b.content.title);
else if (filter[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
}
// default DATEADDED DOWN
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
});
import WatchedList from "@/lib/WatchedList.svelte";
import { watchedList } from "@/store";
</script>
<svelte:head>
<title>Watched List</title>
</svelte:head>
<PosterList>
{#if watched?.length > 0}
{#each watched as w (w.id)}
<Poster
media={{
id: w.content.tmdbId,
poster_path: w.content.poster_path,
title: w.content.title,
overview: w.content.overview,
media_type: w.content.type
}}
rating={w.rating}
status={w.status}
onStatusChanged={(t) => updateWatched(w.content.tmdbId, w.content.type, t)}
onRatingChanged={(r) => updateWatched(w.content.tmdbId, w.content.type, undefined, r)}
onDeleteClicked={() => removeWatched(w.id)}
/>
{/each}
{:else}
You don't have any watched content yet!
{/if}
</PosterList>
<WatchedList list={$watchedList} />
+157
View File
@@ -0,0 +1,157 @@
<script lang="ts">
import { watchedList } from "@/store";
import PageError from "@/lib/PageError.svelte";
import Spinner from "@/lib/Spinner.svelte";
import axios from "axios";
import type {
TMDBDiscoverMovies,
TMDBDiscoverShows,
TMDBTrendingAll,
TMDBUpcomingMovies,
TMDBUpcomingShows
} from "@/types";
import Poster from "@/lib/Poster.svelte";
import { getWatchedDependedProps } from "@/lib/util/helpers";
import PosterList from "@/lib/PosterList.svelte";
$: wList = $watchedList;
async function allTrending() {
return (await axios.get(`/content/trending`)).data as TMDBTrendingAll;
}
async function trendingMovies() {
return (await axios.get(`/content/discover/movies`)).data as TMDBDiscoverMovies;
}
async function trendingShows() {
return (await axios.get(`/content/discover/tv`)).data as TMDBDiscoverShows;
}
async function upcomingMovies() {
return (await axios.get(`/content/upcoming/movies`)).data as TMDBUpcomingMovies;
}
async function upcomingShows() {
return (await axios.get(`/content/upcoming/tv`)).data as TMDBUpcomingShows;
}
</script>
<svelte:head>
<title>Discover Content</title>
</svelte:head>
<div class="page">
<h1>Discover</h1>
<h2 class="norm">Trending Today</h2>
{#await allTrending()}
<Spinner />
{:then trending}
<PosterList type="vertical">
{#each trending.results as trend}
<!-- Possible a person gets returned, but i don't think anyone cares for them -->
{#if trend.media_type === "movie" || trend.media_type === "tv"}
<Poster
media={{ ...trend, media_type: trend.media_type }}
{...getWatchedDependedProps(trend.id, trend.media_type, wList)}
small={true}
/>
{/if}
{/each}
</PosterList>
{:catch err}
<PageError pretty="Failed to load currently trending!" error={err} />
{/await}
<h2 class="norm">Trending Movies</h2>
{#await trendingMovies()}
<Spinner />
{:then movies}
<PosterList type="vertical">
{#each movies.results as movie}
<Poster
media={{ ...movie, media_type: "movie" }}
{...getWatchedDependedProps(movie.id, "movie", wList)}
small={true}
/>
{/each}
</PosterList>
{:catch err}
<PageError pretty="Failed to load discovered movies!" error={err} />
{/await}
<h2 class="norm">Trending Shows</h2>
{#await trendingShows()}
<Spinner />
{:then shows}
<PosterList type="vertical">
{#each shows.results as tv}
<Poster
media={{ ...tv, media_type: "tv" }}
{...getWatchedDependedProps(tv.id, "tv", wList)}
small={true}
/>
{/each}
</PosterList>
{:catch err}
<PageError pretty="Failed to load discovered shows!" error={err} />
{/await}
<h2 class="norm">Upcoming Movies</h2>
{#await upcomingMovies()}
<Spinner />
{:then shows}
<PosterList type="vertical">
{#each shows.results as tv}
<Poster
media={{ ...tv, media_type: "movie" }}
{...getWatchedDependedProps(tv.id, "movie", wList)}
small={true}
/>
{/each}
</PosterList>
{:catch err}
<PageError pretty="Failed to load upcoming movies!" error={err} />
{/await}
<h2 class="norm">Upcoming Shows</h2>
{#await upcomingShows()}
<Spinner />
{:then shows}
<PosterList type="vertical">
{#each shows.results as tv}
<Poster
media={{ ...tv, media_type: "tv" }}
{...getWatchedDependedProps(tv.id, "tv", wList)}
small={true}
/>
{/each}
</PosterList>
{:catch err}
<PageError pretty="Failed to load upcoming shows!" error={err} />
{/await}
</div>
<style lang="scss">
.page {
display: flex;
flex-flow: column;
margin-left: auto;
margin-right: auto;
padding: 20px 50px;
max-width: 1200px;
h1 {
margin-bottom: 15px;
}
h2 {
font-variant: small-caps;
}
@media screen and (max-width: 500px) {
padding: 20px;
}
}
</style>
+221
View File
@@ -0,0 +1,221 @@
<!--
/import is for getting the user to select
the file they want to import and reading
it. The data is set in a store for
/import/process to process.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import Icon from "@/lib/Icon.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import { importedList } from "@/store";
import { onMount } from "svelte";
let fileInput: HTMLInputElement;
let dragEnterTarget: EventTarget | null;
let isDragOver = false;
let isLoading = false;
function processFiles(files?: FileList | null) {
try {
console.log("processFiles", files);
if (!files) {
console.error("processFiles", "No files to process!");
return;
}
isLoading = true;
if (files.length > 1) {
notify({
type: "error",
text: "Only one file at a time is supported. Continuing with the first.",
time: 6000
});
}
// Currently only support for importing one file at a time
const file = files[0];
if (file.type !== "text/plain") {
notify({
type: "error",
text: "Currently only text files are supported"
});
isLoading = false;
isDragOver = false;
return;
}
const r = new FileReader();
r.addEventListener(
"load",
() => {
if (r.result) {
importedList.set({
file,
data: r.result.toString()
});
goto("/import/process");
}
},
false
);
r.readAsText(file);
} catch (err) {
isLoading = false;
notify({ type: "error", text: "Failed to read file!" });
console.error("import: Failed to read file!", err);
}
}
function importFile() {
fileInput.click();
}
onMount(() => {
if (!localStorage.getItem("token")) {
goto("/login");
}
if (fileInput) {
fileInput.addEventListener("change", (ev) => {
processFiles(fileInput.files);
});
}
});
</script>
<div class="content">
<div class="inner">
<span class="header">
<h2>Import Your Watchlist</h2>
<h5 class="norm">beta</h5>
</span>
<div class="big-btns">
{#if isLoading}
<Spinner />
{:else}
<button
on:click={importFile}
on:dragover={(ev) => {
ev.preventDefault();
ev.stopPropagation();
}}
on:dragenter={(ev) => {
ev.preventDefault();
ev.stopPropagation();
dragEnterTarget = ev.target;
console.log("enter");
isDragOver = true;
}}
on:dragleave={(ev) => {
ev.preventDefault();
ev.stopPropagation();
if (dragEnterTarget === ev.target) {
console.log("leave");
isDragOver = false;
}
}}
on:drop={(ev) => {
ev.preventDefault();
ev.stopPropagation();
processFiles(ev.dataTransfer?.files);
}}
class={isDragOver ? "dragging-over" : ""}
>
<Icon i={isDragOver ? "add" : "document"} wh="100%" />
<div>
<h4 class="norm">
{#if isDragOver}
Import
{:else}
Browse
{/if}
</h4>
<!-- <h5 class="norm">Or Drag And Drop</h5> -->
</div>
</button>
{/if}
</div>
</div>
<input type="file" bind:this={fileInput} />
</div>
<style lang="scss">
.content {
display: flex;
width: 100%;
justify-content: center;
padding: 0 30px 0 30px;
.inner {
display: flex;
flex-flow: column;
min-width: 400px;
max-width: 400px;
overflow: hidden;
}
.header {
display: flex;
gap: 10px;
h5 {
margin-top: 3px;
}
}
.big-btns {
display: flex;
justify-content: center;
flex-flow: row;
gap: 20px;
margin-top: 20px;
button {
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
gap: 10px;
height: 180px;
padding: 20px;
background-color: $accent-color;
border: unset;
border-radius: 10px;
user-select: none;
transition: 180ms ease-in-out;
:global {
#reel path {
transition: 180ms ease-in-out;
&:first-of-type {
fill: transparent;
}
&:last-of-type {
fill: $text-color;
}
}
}
&:hover,
&.dragging-over {
color: $bg-color;
background-color: $accent-color-hover;
:global(#reel path:last-of-type) {
fill: $bg-color;
}
}
}
}
input[type="file"] {
width: 0px;
overflow: hidden;
border: unset;
background-color: transparent;
position: absolute;
top: -500px;
}
}
</style>
@@ -0,0 +1,489 @@
<!--
/import/process is for processing the
selected files data. Here it will be
displayed and imported.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import DropDown from "@/lib/DropDown.svelte";
import Error from "@/lib/Error.svelte";
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Poster from "@/lib/Poster.svelte";
import PosterList from "@/lib/PosterList.svelte";
import Spinner from "@/lib/Spinner.svelte";
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
import { sleep } from "@/lib/util/helpers";
import { notify } from "@/lib/util/notify";
import { importedList, parsedImportedList, watchedList } from "@/store";
import {
ImportResponseType,
type ImportResponse,
type ContentSearchTv,
type ContentSearchMovie,
type ImportedList
} from "@/types";
import axios from "axios";
import { onDestroy } from "svelte";
import { get } from "svelte/store";
const wList = get(watchedList);
interface ImportedListItemMultiProblem {
original: ImportedList;
results: (ContentSearchMovie | ContentSearchTv)[];
callback: (err: Error | string | undefined) => void;
}
let rList: ImportedList[] = [];
let isImporting = false;
let cancelled = false;
onDestroy(() => {
cancelled = true;
});
// Set when current item being imported gets an IMPORT_MULTI
// response, which then shows the modal for user to pick correct item.
let importMultiItem: ImportedListItemMultiProblem | undefined;
async function getList() {
const list = get(importedList);
if (!list) {
console.log("import/process, no list, returning to /import");
goto("/import");
return;
}
console.log("getList", list);
if (list?.file.type === "text/plain") {
// Regex to match a year in between brackets,
// which we assume is the release year of content.
const yearRegex = new RegExp(/\([0-9]{4}\)/);
const s = list.data.split("\n");
for (let i = 0; i < s.length; i++) {
const el = s[i]?.trim();
if (el) {
const l: ImportedList = { name: el };
const year = el.match(yearRegex);
if (year && year.length > 0) {
l.year = year[0].replaceAll(/\(|\)/g, "");
l.name = l.name.replace(yearRegex, "").trim();
}
rList.push(l);
}
}
}
// TODO: remove duplicate names in list
return list;
}
function addRow(ev: FocusEvent & { currentTarget: EventTarget & HTMLInputElement }) {
if (!ev.currentTarget.value) {
return;
}
const lo = { name: ev.currentTarget.value } as ImportedList;
const yearEl = document.getElementById("addYear") as HTMLInputElement;
if (yearEl?.value) {
lo.year = yearEl.value;
}
rList.push(lo);
rList = rList;
ev.currentTarget.value = "";
yearEl.value = "";
}
function removeRow(l: ImportedList) {
rList = rList.filter((r) => r.name !== l.name);
rList = rList;
}
async function startImport() {
console.log(rList);
isImporting = true;
window.scrollTo(0, 0);
for (let i = 0; i < rList.length; i++) {
if (cancelled) {
notify({ type: "error", text: "Importing Cancelled" });
return;
}
const li = rList[i];
try {
console.log("Importing", li);
await doImport(li);
await sleep(2000);
} catch (err) {
console.error("Failed to import item:", li, "reason:", err);
}
}
importedList.set(undefined);
if (
rList.some(
(i) =>
i.state == ImportResponseType.IMPORT_FAILED ||
i.state == ImportResponseType.IMPORT_NOTFOUND
)
) {
// Some items failed.. go to some-failed
parsedImportedList.set(rList);
goto("/import/some-failed");
} else {
notify({ type: "success", text: "All content successfully imported!" });
goto("/");
}
}
async function doImport(item: ImportedList) {
if (!item.name?.trim()) {
item.state = ImportResponseType.IMPORT_NOTFOUND;
rList = rList;
return;
}
const resp = await axios.post<ImportResponse>("/import", item);
return new Promise((res, rej) => {
if (resp.data.type === ImportResponseType.IMPORT_MULTI) {
console.log("Import found multiple responses for content", resp.data);
let results = resp.data.results;
if (item.year) {
results = results.sort((a, b) => {
try {
const ar = a.media_type === "movie" ? a.release_date : a.first_air_date;
const ay = ar ? new Date(Date.parse(ar)).getFullYear() : undefined;
const br = b.media_type === "movie" ? b.release_date : b.first_air_date;
const by = br ? new Date(Date.parse(br)).getFullYear() : undefined;
if (ay == item.year) return -1;
else if (by == item.year) return 1;
} catch (err) {
console.error("doImport: results sort failed", err);
}
return 0;
});
}
importMultiItem = {
original: item,
results: resp.data.results,
callback: (err) => {
if (err) {
item.state = ImportResponseType.IMPORT_NOTFOUND;
rList = rList;
rej(err);
} else {
res(0);
}
}
};
} else if (resp.data.type === ImportResponseType.IMPORT_SUCCESS) {
item.state = ImportResponseType.IMPORT_SUCCESS;
const w = resp.data.watchedEntry;
if (w) {
const release =
w.content.type === "movie" ? w.content.release_date : w.content.first_air_date;
if (release) item.year = String(new Date(Date.parse(release)).getFullYear());
item.type = w.content.type;
wList.push(w);
watchedList.update(() => wList);
}
rList = rList;
res(0);
} else {
item.state = resp.data.type;
rList = rList;
res(0);
}
});
}
</script>
{#await getList()}
<Spinner />
{:then list}
<div class="content">
<div class="inner">
{#if rList}
<h2>Importing {list?.file.name ? list.file.name : ""}</h2>
<h5 class="norm">
{#if !isImporting}
Review your imported list and fix any problems.
{:else}
You can fix any failed imports when the process completes.
{/if}
</h5>
<table class={isImporting ? "is-importing" : ""}>
<tr>
{#if isImporting}
<th class="loading-col"></th>
{/if}
<th>Name</th>
<th>Year</th>
<th>Type</th>
{#if !isImporting}
<th></th>
{/if}
</tr>
{#each rList as l}
<tr>
{#if isImporting}
<td class="icon-cell">
<div>
{#if !l.state}
<SpinnerTiny style="width: 13px;" />
{:else if l.state === ImportResponseType.IMPORT_SUCCESS}
<Icon i="check" wh={22} />
{:else if l.state === ImportResponseType.IMPORT_NOTFOUND}
<Icon i="close" wh={22} />
{:else if l.state === ImportResponseType.IMPORT_FAILED}
<Icon i="close" wh={22} />
{:else if l.state === ImportResponseType.IMPORT_EXISTS}
<Icon i="check" wh={22} />
{/if}
</div>
</td>
{/if}
<td><input class="plain" bind:value={l.name} disabled={isImporting} /></td>
<td class="year">
<input
class="plain"
bind:value={l.year}
placeholder="YYYY"
type="number"
disabled={isImporting}
/>
</td>
<td class="type">
<DropDown
options={["movie", "tv"]}
bind:active={l.type}
placeholder="Type"
blendIn={true}
disabled={isImporting}
/>
</td>
{#if !isImporting}
<td>
<button
class="plain delete"
on:click={() => {
removeRow(l);
}}
>
<Icon i="close" wh="25" />
</button>
</td>
{/if}
</tr>
{/each}
{#if !isImporting}
<tr>
<td><input class="plain" placeholder="Name" on:blur={addRow} /></td>
<td class="year">
<input class="plain" id="addYear" placeholder="YYYY" type="number" />
</td>
<td class="type"></td>
<td></td>
</tr>
{/if}
</table>
<div class="btns">
<button on:click={() => goto("/import")}><Icon i="arrow" />Back</button>
<button on:click={startImport} disabled={isImporting}>Start Importing</button>
</div>
{:else}
<h2>No list</h2>
{/if}
</div>
</div>
<!-- Multiple results found modal -->
{#if importMultiItem}
<Modal
title="Multiple Results Found"
desc="Select the correct item for {importMultiItem.original.name}"
onClose={() => {
importMultiItem?.callback("closed results modal");
importMultiItem = undefined;
}}
>
<PosterList type="vertical">
{#each importMultiItem.results as r}
<Poster
media={r}
small={true}
disableInteraction={true}
hideButtons={true}
onClick={async () => {
const item = rList.find((i) => i.name === importMultiItem?.original.name);
if (item) {
item.tmdbId = r.id;
item.type = r.media_type;
try {
await doImport(item);
importMultiItem?.callback(undefined);
} catch (err) {
importMultiItem?.callback(String(err));
}
importMultiItem = undefined;
} else {
// TODO: show error notif and update state with error icon
}
console.log("multi: Poster clicked", r);
}}
/>
{/each}
</PosterList>
</Modal>
{/if}
{:catch err}
<Error error={err} pretty="Failed to process list!" />
{/await}
<style lang="scss">
.content {
display: flex;
width: 100%;
justify-content: center;
padding: 0 30px 30px 30px;
.inner {
display: flex;
flex-flow: column;
min-width: 400px;
max-width: 600px;
overflow: hidden;
}
}
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;
}
}
&.is-importing {
th {
padding-left: 3px;
}
td {
padding-left: 3px;
input {
padding: 7px 0;
&:focus {
padding: 7px 5px;
padding-left: 3px;
}
}
}
}
td {
padding: 5px;
&.icon-cell {
padding-right: 3px;
& > div {
display: flex;
padding-left: 4px;
}
}
&.year {
width: 70px;
}
&.type {
width: 120px;
}
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;
}
}
}
}
}
.btns {
display: flex;
flex-flow: row;
margin-top: 20px;
button {
width: max-content;
gap: 3px;
&:last-of-type {
margin-left: auto;
}
}
}
</style>
@@ -0,0 +1,110 @@
<!--
/import/some-failed shows the user the
failed imports so they can manually import
them instead.
TODO: It would be better if this page didn't exist
and we instead just let users `re-try` on import/process
table. If any failed, we can hide the success ones (and
add a toggle to show successful ones), user can edit any
failed rows and try import each again.
-->
<script lang="ts">
import { goto } from "$app/navigation";
import { parsedImportedList } from "@/store";
import { ImportResponseType, type ImportedList } from "@/types";
import { onMount } from "svelte";
import { get } from "svelte/store";
let failed: ImportedList[] = [];
let successCount = 0;
onMount(() => {
let list = get(parsedImportedList);
if (list) {
for (let i = 0; i < list.length; i++) {
const item = list[i];
console.log(item);
if (
item.state === ImportResponseType.IMPORT_FAILED ||
item.state === ImportResponseType.IMPORT_NOTFOUND
) {
failed.push(item);
failed = failed;
} else {
successCount++;
}
}
console.log("failedlen", failed.length);
} else {
goto("/import");
}
});
</script>
<div class="content">
<div class="inner">
<h2>Some Content Failed To Import</h2>
<h5 class="norm">You can search for the failed imports and manually add them.</h5>
<h4 class="norm">{successCount} succeeded and {failed.length} failed.</h4>
{#if failed}
<ul>
{#each failed as l}
<li>
<span>{l.name}</span>
</li>
{/each}
</ul>
{:else}
No List
{/if}
</div>
</div>
<style lang="scss">
.content {
display: flex;
width: 100%;
justify-content: center;
padding: 0 30px 30px 30px;
.inner {
display: flex;
flex-flow: column;
min-width: 400px;
max-width: 600px;
overflow: hidden;
}
}
h4 {
margin-top: 15px;
}
ul {
display: flex;
flex-flow: column;
gap: 5px;
margin: 10px;
list-style: none;
li {
display: flex;
flex-flow: row;
align-items: center;
padding: 10px;
background-color: $accent-color;
border-radius: 5px;
a {
margin-left: auto;
button {
width: max-content;
}
}
}
}
</style>

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