Compare commits

..

51 Commits

Author SHA1 Message Date
IRHM b21d2bf603 Plex: Move auth tokens to new UserServices table and get local auth tokens 2024-03-17 17:04:10 +00:00
IRHM 0667f33250 plex sync: dont parse lastViewedAt for seasons/episodes if not set 2024-03-16 23:37:44 +00:00
IRHM 0da264480c plex auth: update auth token on login
hm mite save twice on creation
2024-03-16 23:23:39 +00:00
IRHM 622d10e743 Plex sync seasons and episodes 2024-03-16 23:18:32 +00:00
IRHM 7c216f4691 Plex sync movies and shows
Based on #401 - Seemed easier to port the relevant changes with some modifications than to fix merge conflicts and mess with history.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docker-compose.yml: fix port

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

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

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

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

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

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

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

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

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

* api: Update updateActivity log
2024-02-22 02:33:34 +00:00
Mr 11a0ec55d5 Merge pull request #366 from stephaje/Issue-#61
Issue #61 - Editing activity
2024-02-22 01:48:46 +00:00
John Stephani 14b3f38b5d Add client validation 2024-02-21 19:17:13 -06:00
stephaje 5799cd649b context => c 2024-02-19 12:44:11 -06:00
stephaje d4b873ec7d Clean up errors, CSS, fix tv/games activity 2024-02-18 23:26:31 -06:00
stephaje d00ae04074 Add user id checking; fix log/error messages 2024-02-18 14:00:07 -06:00
stephaje 8955ca7b26 npm run format 2024-02-18 10:52:17 -06:00
stephaje 82c6729a22 Issue #61 2024-02-18 10:47:16 -06:00
47 changed files with 2894 additions and 534 deletions
+5
View File
@@ -40,3 +40,8 @@ vite.config.ts.timestamp-*
# Docker dev volume
container_data/
data/
# Build artifacts
server/ui
server/watcharr
+3
View File
@@ -7,6 +7,7 @@
<a href="https://watcharr.app"><img src="https://img.shields.io/website?label=DOCS&style=for-the-badge&url=https%3A%2F%2Fwatcharr.app" /></a>
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
<a href="https://matrix.to/#/#watcharr:matrix.org"><img src="https://img.shields.io/matrix/watcharr%3Amatrix.org?style=for-the-badge&logo=matrix" /></a>
</p>
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) **movies** and **tv shows** (and **anime**), rate them and track their status.
@@ -55,3 +56,5 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
# Getting Help
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
+1
View File
@@ -22,6 +22,7 @@ services:
volumes:
# Contains all of watcharr data (database & cache)
- ./data:/data
restart: unless-stopped
```
:::danger first account
+9 -1
View File
@@ -54,7 +54,10 @@ const config = {
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
},
theme: {
customCss: require.resolve("./src/css/custom.css")
customCss: [
"./node_modules/viewerjs/dist/viewer.css",
require.resolve("./src/css/custom.css")
]
}
})
]
@@ -80,6 +83,11 @@ const config = {
{
type: "docsVersionDropdown"
},
{
href: "https://beta.watcharr.app",
label: "Demo",
position: "right"
},
{
href: "https://github.com/sbondCo/Watcharr",
label: "GitHub",
+7 -1
View File
@@ -14,7 +14,8 @@
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"viewerjs": "^1.11.6"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.1.1"
@@ -14119,6 +14120,11 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/viewerjs": {
"version": "1.11.6",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.11.6.tgz",
"integrity": "sha512-TlhdSp2oEOLFXvEp4psKaeTjR5zBjTRcM/sHUN8PkV1UWuY8HKC8n7GaVdW5Xqnwdr/F1OmzLik1QwDjI4w/nw=="
},
"node_modules/watchpack": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
+2 -1
View File
@@ -20,7 +20,8 @@
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"viewerjs": "^1.11.6"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.1.1"
+71
View File
@@ -0,0 +1,71 @@
import React, { useEffect } from "react";
import styles from "./styles.module.css";
import clsx from "clsx";
import Viewer from "viewerjs";
function TwoColFeat({ title, description, imgSrc }) {
return (
<div className={clsx(styles.twoCol)}>
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
<div>
<img src={imgSrc} alt="" loading="lazy" />
</div>
</div>
);
}
export function HomepageTwoColFeats() {
useEffect(() => {
const ctr = document.getElementById("homepage-two-col-feats");
console.debug("Starting image viewer", ctr);
new Viewer(ctr, {
navbar: 0,
toolbar: {
zoomIn: 2,
zoomOut: 2,
next: 2,
prev: 2
},
title: 0,
className: "twoColViewer"
});
}, []);
return (
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
<TwoColFeat
title="Neat UI"
description="We just look lightweight."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
/>
<TwoColFeat
title="Easy To Use"
description="Not much to it, other than ease of use."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
/>
<TwoColFeat
title="In-Depth Details"
description="About your favourite content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
/>
<TwoColFeat
title="Personal Stats and Configuration"
description="Make it how you like."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
/>
<TwoColFeat
title="Discovery"
description="Find the latest trending content."
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
/>
<TwoColFeat
title="Dark Theme"
description="For you who are still unsatisfied. ;("
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
/>
</section>
);
}
@@ -0,0 +1,53 @@
.twoColFeats {
display: flex;
align-items: center;
margin-left: auto;
margin-right: auto;
flex-flow: column;
width: 100%;
max-width: 1300px;
}
.twoCol {
display: flex;
flex-flow: row;
width: 100%;
padding: 2.5rem 5rem;
gap: 50px;
background-color: rgb(236, 236, 236);
}
.twoCol:nth-child(even) {
flex-flow: row-reverse;
background-color: transparent;
}
.twoCol > div {
width: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
}
.twoCol > div > img {
width: 100%;
border-radius: 12px;
box-shadow: 0px 0px 5px rgb(121, 121, 121);
cursor: pointer;
}
@media screen and (max-width: 850px) {
.twoCol,
.twoCol:nth-child(even) {
flex-flow: column;
}
.twoCol > div {
width: 100%;
}
.twoCol > div > img {
max-width: 500px;
}
}
+14
View File
@@ -28,3 +28,17 @@
--ifm-color-primary-lightest: #4fddbf;
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
}
.twoColViewer .viewer-close {
right: 10px;
top: 10px;
width: 50px;
height: 50px;
}
/* Stupid poopin fix for viewerjs because whatever is bundling the css
isn't keeping the order of this correctly. All this time wasted and it
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
.viewer-in {
opacity: 1 !important;
}
+2
View File
@@ -7,6 +7,7 @@ import LogoCol from "@site/static/img/logo-col.png";
import styles from "./index.module.css";
import clsx from "clsx";
import { HomepageTwoColFeats } from "../components/TwoColFeat";
function HomepageHeader() {
const { siteConfig } = useDocusaurusContext();
@@ -45,6 +46,7 @@ export default function Home() {
<HomepageHeader />
<main>
<HomepageFeatures />
<HomepageTwoColFeats />
</main>
</Layout>
);
+5 -1
View File
@@ -2,9 +2,13 @@ version: "3"
services:
watcharr:
# The :latest tag is used for simplicity, it is recommended
# to use an actual version, then when updating check the releases for changelogs.
image: ghcr.io/sbondco/watcharr:latest
container_name: watcharr
ports:
- 3080:3080
volumes:
- ./container_data:/data
# Contains all of watcharr data (database & cache)
- ./data:/data
restart: unless-stopped
+91 -319
View File
@@ -1,12 +1,12 @@
{
"name": "watcharr",
"version": "1.34.0",
"version": "1.35.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watcharr",
"version": "1.34.0",
"version": "1.35.2",
"dependencies": {
"axios": "^1.6.7",
"blurhash": "^2.0.5",
@@ -14,23 +14,23 @@
},
"devDependencies": {
"@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.0.8",
"@sveltejs/kit": "^2.5.3",
"@types/papaparse": "^5.3.14",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.20.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vite-pwa/sveltekit": "^0.3.0",
"eslint": "^8.56.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-svelte": "^3.2.1",
"svelte": "^4.2.10",
"svelte-check": "^3.6.3",
"svelte-check": "^3.6.4",
"svelte-eslint-parser": "^0.33.1",
"svelte-preprocess": "^5.1.3",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.0.12"
"vite": "^5.1.6"
}
},
"node_modules/@aashutoshrathi/word-wrap": {
@@ -2475,22 +2475,22 @@
}
},
"node_modules/@eslint/js": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
"integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz",
"integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==",
"dev": true,
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz",
"integrity": "sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ==",
"version": "0.11.14",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz",
"integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==",
"dev": true,
"dependencies": {
"@humanwhocodes/object-schema": "^2.0.1",
"debug": "^4.1.1",
"@humanwhocodes/object-schema": "^2.0.2",
"debug": "^4.3.1",
"minimatch": "^3.0.5"
},
"engines": {
@@ -2511,9 +2511,9 @@
}
},
"node_modules/@humanwhocodes/object-schema": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz",
"integrity": "sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz",
"integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==",
"dev": true
},
"node_modules/@jridgewell/gen-mapping": {
@@ -2956,9 +2956,9 @@
}
},
"node_modules/@sveltejs/kit": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.0.tgz",
"integrity": "sha512-1uyXvzC2Lu1FZa30T4y5jUAC21R309ZMRG0TPt+PPPbNUoDpy8zSmSNVWYaBWxYDqLGQ5oPNWvjvvF2IjJ1jmA==",
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.3.tgz",
"integrity": "sha512-s6x7HBn/Fp+UNvyhJohjIA0FcJ+BWHGUDQ4PCg1D0EboUlvbimJQYchINu8G6sspLXYmlcsuNsp8bbcrRk85iw==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
@@ -3077,9 +3077,9 @@
"dev": true
},
"node_modules/@types/semver": {
"version": "7.5.6",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.6.tgz",
"integrity": "sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A==",
"version": "7.5.8",
"resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz",
"integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==",
"dev": true
},
"node_modules/@types/trusted-types": {
@@ -3090,16 +3090,16 @@
"peer": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz",
"integrity": "sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.2.0.tgz",
"integrity": "sha512-mdekAHOqS9UjlmyF/LSs6AIEvfceV749GFxoBAjwAv0nkevfKHWQFDMcBZWUiIC5ft6ePWivXoS36aKQ0Cy3sw==",
"dev": true,
"dependencies": {
"@eslint-community/regexpp": "^4.5.1",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/type-utils": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/type-utils": "7.2.0",
"@typescript-eslint/utils": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"debug": "^4.3.4",
"graphemer": "^1.4.0",
"ignore": "^5.2.4",
@@ -3115,8 +3115,8 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^6.0.0 || ^6.0.0-alpha",
"eslint": "^7.0.0 || ^8.0.0"
"@typescript-eslint/parser": "^7.0.0",
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -3124,63 +3124,16 @@
}
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.20.0.tgz",
"integrity": "sha512-bYerPDF/H5v6V76MdMYhjwmwgMA+jlPVqjSDq2cRqMi8bP5sR3Z+RLOiOMad3nsnmDVmn2gAFCyNgh/dIrfP/w==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-7.2.0.tgz",
"integrity": "sha512-5FKsVcHTk6TafQKQbuIVkXq58Fnbkd2wDL4LB7AURN7RUOu1utVP+G8+6u3ZhEroW3DF6hyo3ZEXxgKgp4KeCg==",
"dev": true,
"dependencies": {
"@typescript-eslint/scope-manager": "6.20.0",
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/typescript-estree": "6.20.0",
"@typescript-eslint/visitor-keys": "6.20.0",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"debug": "^4.3.4"
},
"engines": {
@@ -3191,7 +3144,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -3200,13 +3153,13 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.20.0.tgz",
"integrity": "sha512-p4rvHQRDTI1tGGMDFQm+GtxP1ZHyAh64WANVoyEcNMpaTFn3ox/3CcgtIlELnRfKzSs/DwYlDccJEtr3O6qBvA==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.2.0.tgz",
"integrity": "sha512-Qh976RbQM/fYtjx9hs4XkayYujB/aPwglw2choHmf3zBjB4qOywWSdt9+KLRdHubGcoSwBnXUH2sR3hkyaERRg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/visitor-keys": "6.20.0"
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -3217,13 +3170,13 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.21.0.tgz",
"integrity": "sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-7.2.0.tgz",
"integrity": "sha512-xHi51adBHo9O9330J8GQYQwrKBqbIPJGZZVQTHHmy200hvkLZFWJIFtAG/7IYTWUyun6DE6w5InDReePJYJlJA==",
"dev": true,
"dependencies": {
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/utils": "6.21.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"@typescript-eslint/utils": "7.2.0",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
},
@@ -3235,7 +3188,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
"eslint": "^8.56.0"
},
"peerDependenciesMeta": {
"typescript": {
@@ -3243,92 +3196,10 @@
}
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@typescript-eslint/types": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.20.0.tgz",
"integrity": "sha512-MM9mfZMAhiN4cOEcUOEx+0HmuaW3WBfukBZPCfwSqFnQy0grXYtngKCqpQN339X3RrwtzspWJrpbrupKYUSBXQ==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.2.0.tgz",
"integrity": "sha512-XFtUHPI/abFhm4cbCDc5Ykc8npOKBSJePY3a3s+lwumt7XWJuzP5cZcfZ610MIPHjQjNsOLlYK8ASPaNG8UiyA==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
@@ -3339,13 +3210,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.20.0.tgz",
"integrity": "sha512-RnRya9q5m6YYSpBN7IzKu9FmLcYtErkDkc8/dKv81I9QiLLtVBHrjz+Ev/crAqgMNW2FCsoZF4g2QUylMnJz+g==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.2.0.tgz",
"integrity": "sha512-cyxS5WQQCoBwSakpMrvMXuMDEbhOo9bNHHrNcEWis6XHx6KF518tkF1wBvKIn/tpq5ZpUYK7Bdklu8qY0MsFIA==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/visitor-keys": "6.20.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/visitor-keys": "7.2.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
@@ -3391,17 +3262,17 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.21.0.tgz",
"integrity": "sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.2.0.tgz",
"integrity": "sha512-YfHpnMAGb1Eekpm3XRK8hcMwGLGsnT6L+7b2XyRv6ouDuJU1tZir1GS2i0+VXRatMwSI1/UfcyPe53ADkU+IuA==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"@types/json-schema": "^7.0.12",
"@types/semver": "^7.5.0",
"@typescript-eslint/scope-manager": "6.21.0",
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/typescript-estree": "6.21.0",
"@typescript-eslint/scope-manager": "7.2.0",
"@typescript-eslint/types": "7.2.0",
"@typescript-eslint/typescript-estree": "7.2.0",
"semver": "^7.5.4"
},
"engines": {
@@ -3412,115 +3283,16 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"eslint": "^7.0.0 || ^8.0.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.21.0.tgz",
"integrity": "sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.21.0.tgz",
"integrity": "sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==",
"dev": true,
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.21.0.tgz",
"integrity": "sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"@typescript-eslint/visitor-keys": "6.21.0",
"debug": "^4.3.4",
"globby": "^11.1.0",
"is-glob": "^4.0.3",
"minimatch": "9.0.3",
"semver": "^7.5.4",
"ts-api-utils": "^1.0.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.21.0.tgz",
"integrity": "sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.21.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
"node": "^16.0.0 || >=18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/@typescript-eslint/utils/node_modules/minimatch": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz",
"integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==",
"dev": true,
"dependencies": {
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
"eslint": "^8.56.0"
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.20.0.tgz",
"integrity": "sha512-E8Cp98kRe4gKHjJD4NExXKz/zOJ1A2hhZc+IMVD6i7w4yjIvh6VyuRI0gRtxAsXtoC35uGMaQ9rjI2zJaXDEAw==",
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.2.0.tgz",
"integrity": "sha512-c6EIQRHhcpl6+tO8EMR+kjkkV+ugUNXOmeASA1rlzkd8EPIriavpWoiEz1HR/VLhbVIdhqnV6E7JZm00cBDx2A==",
"dev": true,
"dependencies": {
"@typescript-eslint/types": "6.20.0",
"@typescript-eslint/types": "7.2.0",
"eslint-visitor-keys": "^3.4.1"
},
"engines": {
@@ -4456,16 +4228,16 @@
}
},
"node_modules/eslint": {
"version": "8.56.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
"version": "8.57.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz",
"integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^2.1.4",
"@eslint/js": "8.56.0",
"@humanwhocodes/config-array": "^0.11.13",
"@eslint/js": "8.57.0",
"@humanwhocodes/config-array": "^0.11.14",
"@humanwhocodes/module-importer": "^1.0.1",
"@nodelib/fs.walk": "^1.2.8",
"@ungap/structured-clone": "^1.2.0",
@@ -6231,9 +6003,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.32",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.32.tgz",
"integrity": "sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==",
"version": "8.4.35",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
"dev": true,
"funding": [
{
@@ -6367,9 +6139,9 @@
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz",
"integrity": "sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==",
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.1.tgz",
"integrity": "sha512-ENAPbIxASf2R79IZwgkG5sBdeNA9kLRlXVvKKmTXh79zWTy0KKoT86XO2pHrTitUPINd+iXWy12MRmgzKGVckA==",
"dev": true,
"peerDependencies": {
"prettier": "^3.0.0",
@@ -7134,9 +6906,9 @@
}
},
"node_modules/svelte-check": {
"version": "3.6.3",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.3.tgz",
"integrity": "sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==",
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.4.tgz",
"integrity": "sha512-mY/dqucqm46p72M8yZmn81WPZx9mN6uuw8UVfR3ZKQeLxQg5HDGO3HHm5AZuWZPYNMLJ+TRMn+TeN53HfQ/vsw==",
"dev": true,
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.17",
@@ -7665,13 +7437,13 @@
"dev": true
},
"node_modules/vite": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.12.tgz",
"integrity": "sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.6.tgz",
"integrity": "sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==",
"dev": true,
"dependencies": {
"esbuild": "^0.19.3",
"postcss": "^8.4.32",
"postcss": "^8.4.35",
"rollup": "^4.2.0"
},
"bin": {
+8 -8
View File
@@ -1,6 +1,6 @@
{
"name": "watcharr",
"version": "1.34.0",
"version": "1.35.2",
"private": true,
"scripts": {
"dev": "vite dev",
@@ -14,23 +14,23 @@
},
"devDependencies": {
"@sveltejs/adapter-node": "^4.0.1",
"@sveltejs/kit": "^2.0.8",
"@sveltejs/kit": "^2.5.3",
"@types/papaparse": "^5.3.14",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.20.0",
"@typescript-eslint/eslint-plugin": "^7.2.0",
"@typescript-eslint/parser": "^7.2.0",
"@vite-pwa/sveltekit": "^0.3.0",
"eslint": "^8.56.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.35.1",
"prettier": "^3.2.5",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-svelte": "^3.2.1",
"svelte": "^4.2.10",
"svelte-check": "^3.6.3",
"svelte-check": "^3.6.4",
"svelte-eslint-parser": "^0.33.1",
"svelte-preprocess": "^5.1.3",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.0.12"
"vite": "^5.1.6"
},
"type": "module",
"dependencies": {
+65 -17
View File
@@ -11,23 +11,31 @@ import (
type ActivityType string
var (
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
RATING_CHANGED ActivityType = "RATING_CHANGED"
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
SEASON_ADDED ActivityType = "SEASON_ADDED"
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
RATING_CHANGED ActivityType = "RATING_CHANGED"
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
SEASON_ADDED ActivityType = "SEASON_ADDED"
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
)
type Activity struct {
@@ -53,6 +61,10 @@ type ActivityAddRequest struct {
CustomDate *time.Time `json:"customDate,omitempty"`
}
type ActivityUpdateRequest struct {
CustomDate time.Time `json:"customDate" binding:"required"`
}
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
activity := new([]Activity)
res := db.Model(&Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
@@ -76,3 +88,39 @@ func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, err
slog.Debug("Adding activity", "added_activity", activity)
return activity, nil
}
func updateActivity(db *gorm.DB, userId uint, id uint, activityUpdateRequest ActivityUpdateRequest) error {
if id == 0 {
return errors.New("id must be set to update an activity")
}
if activityUpdateRequest.CustomDate.IsZero() {
return errors.New("customDate must be set to update an activity")
}
res := db.Model(&Activity{}).Where("user_id = ? AND id = ?", userId, id).Update("custom_date", activityUpdateRequest.CustomDate)
if res.Error != nil {
slog.Error("Error updating activity in database", "error", res.Error.Error())
return errors.New("failed updating activity in database")
}
if res.RowsAffected < 1 {
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
return errors.New("failed updating activity in database")
}
slog.Debug("Updating activity", "updated_activity", id)
return nil
}
func deleteActivity(db *gorm.DB, userId uint, id uint) error {
if id == 0 {
return errors.New("an id must be provided to delete an activity")
}
res := db.Where("user_id = ?", userId).Delete(&Activity{}, id)
if res.Error != nil {
slog.Error("Error deleting activity in database", "error", res.Error.Error())
return errors.New("failed deleting activity in database")
}
if res.RowsAffected < 1 {
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
return errors.New("failed deleting activity from database")
}
return nil
}
+104 -3
View File
@@ -14,6 +14,7 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
@@ -28,6 +29,7 @@ type UserType uint8
var (
// Assume watcharr user if none of these...
JELLYFIN_USER UserType = 1
PLEX_USER UserType = 2
)
// User Perms
@@ -56,8 +58,10 @@ type User struct {
ThirdPartyID string `json:"-"`
// Auth token from third party (jellyfin)
ThirdPartyAuth string `json:"-"`
Watched []Watched
Permissions int `gorm:"default:1" json:"-"`
// Users third party integrations (minus jellyfin for now)
UserServices []UserServices `json:"-"`
Watched []Watched
Permissions int `gorm:"default:1" json:"-"`
// All user settings cols, in another struct for reusability
UserSettings
}
@@ -86,6 +90,30 @@ type UserSettings struct {
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
}
// Holds third party service auth tokens for users.
// Each service may use the fields in their own way.
// Unique index applied between service name and clientID
// to ensure no duplicates (no need to apply it against
// user_id, no accounts should share an integration).
//
// Plex:
// - AuthToken : Used for requests against plex.tv
// - AuthToken2 : Used for requests against home plex server.
type UserServices struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// Service/integration name
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
// The users id on the third party service
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
AuthToken string `gorm:"not null;" json:"-"`
// Second auth token, generic name so future services can use it without extra confusion.
// Ex: We require a second auth token for use with our local server for Plex.
AuthToken2 string `json:"-"`
UserID uint `gorm:"not null;" json:"-"`
}
// We use a separate struct for registration to avoid confusion
// and possible accidents where we allow a user to pass in a
// property from the main User struct that shouldn't be allowed.
@@ -369,7 +397,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
}
dbUser := new(User)
dbRes := db.Where("third_party_id = ?", resp.User.ID).Take(&dbUser)
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, JELLYFIN_USER).Take(&dbUser)
if dbRes.Error != nil {
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
// Record not found, so we should create the user
@@ -403,6 +431,79 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
return AuthResponse{Token: token}, nil
}
// Login via Plex.
func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
slog.Error("Request made to login via Plex, but Plex authentication is disabled")
return AuthResponse{}, errors.New("plex login not enabled")
}
slog.Debug("A Plex User Is Logging In")
account, err := fetchPlexAccountFromToken(lr.AuthToken)
if err != nil {
slog.Error("loginPlex: Could not fetch Plex account", "error", err)
return AuthResponse{}, errors.New("could not fetch plex acount")
}
if account.Username == "" || account.Id == 0 {
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
return AuthResponse{}, errors.New("data is missing from the plex account response")
}
dbUser := new(User)
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
if dbRes.Error != nil {
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
if err := plexUserHasAccessToPlexHost(lr.AuthToken); err != nil {
slog.Error("loginPlex: Cannot register Plex user. Failed to verify they have access to our home plex server.", "error", err)
return AuthResponse{}, errors.New("failed to verify plex access")
}
dbUser.Username = account.Username
dbUser.Type = PLEX_USER
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
if err != nil {
slog.Error("loginPlex: Failed to get home server auth token for the new user! User will still be created, a re-login may fix this issue.", "error", err)
}
dbUser.UserServices = append(dbUser.UserServices, UserServices{
Name: "plex",
ClientID: strconv.FormatUint(account.Id, 10),
AuthToken: lr.AuthToken,
AuthToken2: homeAuthToken,
})
dbRes = db.Create(&dbUser)
if dbRes.Error != nil {
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
return AuthResponse{}, errors.New("failed to create new user from plex")
}
} else {
slog.Error("loginPlex: Failed to select user from database for login", "error", dbRes.Error)
return AuthResponse{}, errors.New("failed to locate user")
}
} else {
// If user exists.. update their access tokens in db
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
if err != nil {
slog.Error("loginPlex: Failed to get home server auth token!", "error", err)
}
for i, v := range dbUser.UserServices {
if v.Name == "plex" {
slog.Info("loginPlex: Found plex user service.. attemping to update")
dbUser.UserServices[i].AuthToken = lr.AuthToken
if homeAuthToken != "" {
dbUser.UserServices[i].AuthToken2 = homeAuthToken
}
break
}
}
db.Save(&dbUser.UserServices)
}
token, err := signJWT(dbUser)
if err != nil {
slog.Error("loginPlex: Failed to sign new jwt", "error", err)
return AuthResponse{}, errors.New("failed to get auth token")
}
return AuthResponse{Token: token}, nil
}
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
var dbToken Token
resp := db.Where("value = ?", req.Token).Take(&dbToken)
+27 -16
View File
@@ -25,12 +25,21 @@ type ServerConfig struct {
// Enable/disable signup functionality.
// Set to `false` to disable registering an account.
SIGNUP_ENABLED bool `json:",omitempty"`
SIGNUP_ENABLED bool
// Optional: Provide your own TMDB API Key.
// If unprovided, the default Watcharr API key will be used.
TMDB_KEY string `json:",omitempty"`
// Optional: Point to Plex install to enable plex features.
PLEX_HOST string `json:",omitempty"`
// Optional: Machine identifier of your Plex server.
// This is used to ensure only users of your Plex server
// can use this Watcharr instance.
// Will be fetched automatically when PLEX_HOST is provided via web ui.
PLEX_MACHINE_ID string `json:",omitempty"`
SONARR []SonarrSettings `json:",omitempty"`
RADARR []RadarrSettings `json:",omitempty"`
TWITCH game.IGDB `json:",omitempty"`
@@ -50,12 +59,14 @@ type ServerConfig struct {
// not editable on frontend, so not needed).
func (c *ServerConfig) GetSafe() ServerConfig {
return ServerConfig{
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
JELLYFIN_HOST: c.JELLYFIN_HOST,
TMDB_KEY: c.TMDB_KEY,
DEBUG: c.DEBUG,
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
JELLYFIN_HOST: c.JELLYFIN_HOST,
TMDB_KEY: c.TMDB_KEY,
PLEX_HOST: c.PLEX_HOST,
PLEX_MACHINE_ID: c.PLEX_MACHINE_ID,
DEBUG: c.DEBUG,
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
TWITCH: game.IGDB{
ClientID: c.TWITCH.ClientID,
ClientSecret: c.TWITCH.ClientSecret,
@@ -64,11 +75,8 @@ func (c *ServerConfig) GetSafe() ServerConfig {
}
var (
// Our server config.. set defaults here, then `readConfig`
// will overwrite if provided in watcharr.json cfg file.
Config = ServerConfig{
SIGNUP_ENABLED: true,
}
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
Config = ServerConfig{}
)
// Read config file
@@ -102,19 +110,22 @@ func initFromConfig() error {
}
// Generate new barebones watcharr.json config file.
// Currently only JWT_SECRET is required, so this method
// generates a secret.
// Generates a JWT_SECRET and set default config.
func generateConfig() error {
key, err := generateString(64)
if err != nil {
return err
}
cfg := ServerConfig{JWT_SECRET: key}
cfg := ServerConfig{
JWT_SECRET: key,
// Other defaults..
SIGNUP_ENABLED: true,
}
barej, err := json.MarshalIndent(cfg, "", "\t")
if err != nil {
return err
}
Config.JWT_SECRET = cfg.JWT_SECRET
Config = cfg
return os.WriteFile("./data/watcharr.json", barej, 0755)
}
+1 -1
View File
@@ -242,7 +242,7 @@ func tvCredits(id string) (TMDBContentCredits, error) {
resp := new(TMDBContentCredits)
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
if err != nil {
slog.Error("Failed to complete tv cast request!", err.Error())
slog.Error("Failed to complete tv cast request!", "error", err.Error())
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
}
return *resp, nil
+15 -15
View File
@@ -5,11 +5,11 @@ go 1.21
require (
github.com/buckket/go-blurhash v1.1.0
github.com/gin-contrib/cache v1.2.0
github.com/gin-contrib/cors v1.5.0
github.com/gin-contrib/cors v1.7.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.19.0
golang.org/x/crypto v0.21.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.7
@@ -17,36 +17,36 @@ require (
require (
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
github.com/bytedance/sonic v1.10.2 // indirect
github.com/bytedance/sonic v1.11.2 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.0 // indirect
github.com/chenzhuoyu/iasm v0.9.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.15.5 // indirect
github.com/go-playground/validator/v10 v10.19.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/gomodule/redigo v1.8.9 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/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/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/memcachier/mc/v3 v3.0.3 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.5.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.17.0 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.7.0 // indirect
golang.org/x/net v0.22.0 // indirect
golang.org/x/sys v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+30 -34
View File
@@ -4,14 +4,15 @@ github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZ
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/bytedance/sonic v1.11.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -20,8 +21,8 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq
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.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
github.com/gin-contrib/cors v1.7.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
@@ -32,16 +33,14 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -54,17 +53,17 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
@@ -74,8 +73,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
@@ -90,30 +89,27 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+45 -12
View File
@@ -8,6 +8,9 @@ import (
"log/slog"
"net/http"
"net/url"
"time"
"github.com/gin-gonic/gin"
)
type JellyfinItemSearchResponse struct {
@@ -15,12 +18,27 @@ type JellyfinItemSearchResponse struct {
}
type JellyfinItems struct {
Name string `json:"Name"`
Type string `json:"Type"`
ServerID string `json:"ServerId"`
Id string `json:"Id"`
ProviderIds struct {
Tmdb string `json:"Tmdb"`
} `json:"ProviderIds"`
UserData struct {
Rating float64 `json:"Rating"`
PlayedPercentage float64 `json:"PlayedPercentage"`
UnplayedItemCount int64 `json:"UnplayedItemCount"`
PlaybackPositionTicks int64 `json:"PlaybackPositionTicks"`
PlayCount int64 `json:"PlayCount"`
IsFavorite bool `json:"IsFavorite"`
Likes bool `json:"Likes"`
LastPlayedDate time.Time `json:"LastPlayedDate"`
Played bool `json:"Played"`
Key string `json:"Key"`
ItemId string `json:"ItemId"`
} `json:"UserData"`
RecursiveItemCount int64 `json:"RecursiveItemCount"`
}
type JFContentFindResponse struct {
@@ -28,6 +46,33 @@ type JFContentFindResponse struct {
Url string `json:"url"`
}
// Jellyfin access middleware, ensures user is a jellyfin user.
// To be ran after AuthRequired middleware with extra data.
func JellyfinAccessRequired() gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
slog.Debug("JellyfinAccessRequired middleware hit", "user_id", userId)
userType := c.MustGet("userType").(UserType)
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
if Config.JELLYFIN_HOST == "" {
slog.Error("JellyfinAccessRequired: Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
c.AbortWithStatus(401)
return
}
if userType != JELLYFIN_USER || userThirdPartyId == "" {
slog.Error("JellyfinAccessRequired: User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
c.AbortWithStatus(401)
return
}
if userThirdPartyAuth == "" {
slog.Error("JellyfinAccessRequired: User has no thirdPartyAuth token..")
c.AbortWithStatus(401)
return
}
}
}
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
if Config.JELLYFIN_HOST == "" {
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
@@ -97,18 +142,6 @@ func jellyfinContentFind(
contentName string,
contentTmdbId string,
) (JFContentFindResponse, error) {
if Config.JELLYFIN_HOST == "" {
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
}
if userType != JELLYFIN_USER || userThirdPartyId == "" {
slog.Error("User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
return JFContentFindResponse{}, errors.New("not jellyfin user")
}
if userThirdPartyAuth == "" {
slog.Error("User has no thirdPartyAuth token..")
return JFContentFindResponse{}, errors.New("user has no jellyfin auth token")
}
if contentType == "" || contentName == "" {
slog.Error("Bad request", "content_type", contentType, "content_name", contentName)
return JFContentFindResponse{}, errors.New("content type or name not provided")
+311
View File
@@ -0,0 +1,311 @@
package main
import (
"errors"
"log/slog"
"strconv"
"gorm.io/gorm"
)
type JellyfinSeriesSeasonsResponse struct {
Items []JellyfinSeriesSeasonItem `json:"Items"`
}
type JellyfinSeriesSeasonItem struct {
JellyfinItems
// aka the season number
IndexNumber int `json:"IndexNumber"`
}
type JellyfinSeriesEpisodesResponse struct {
Items []JellyfinSeriesEpisodeItem `json:"Items"`
}
type JellyfinSeriesEpisodeItem struct {
JellyfinItems
// the episode number
IndexNumber int `json:"IndexNumber"`
// the episodes season number
ParentIndexNumber int `json:"ParentIndexNumber"`
}
type JellyfinSyncResponse struct {
JobId string `json:"jobId"`
}
// Perform the jellyfin sync.
// Gets each type of media separately from jellyfin and attempts to import them.
// Errors are added silently to the job.
func startJellyfinSync(
db *gorm.DB,
jobId string,
userId uint,
username string,
userThirdPartyId string,
userThirdPartyAuth string,
) {
// Get played movies
updateJobCurrentTask(jobId, userId, "syncing movies")
playedMovies := new(JellyfinItemSearchResponse)
err := jellyfinAPIRequest(
"GET",
"/Users/"+userThirdPartyId+"/Items",
map[string]string{
"Filters": "IsPlayed",
"IncludeItemTypes": "Movie",
"Fields": "ProviderIds",
"Recursive": "true",
},
username,
userThirdPartyAuth,
&playedMovies,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
addJobError(jobId, userId, "failed to get jellyfin response for movies")
} else {
if len(playedMovies.Items) <= 0 {
slog.Info("jellyfinSyncWatched: User has no played movies.", "user_id", userId)
} else {
for _, v := range playedMovies.Items {
slog.Info("jellyfinSyncWatched: Importing played movie.", "movie_name", v.Name, "user_id", userId)
slog.Debug("jellyfinSyncWatched: Importing played movie.", "full_item", v, "user_id", userId)
// 1. Ensure we have a tmdbId
if v.ProviderIds.Tmdb == "" {
slog.Error("jellyfinSyncWatched: Movie to import does not have a tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+v.Name)
continue
}
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
if err != nil {
slog.Error("jellyfinSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+v.Name)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+v.Name)
// 2. Imported watched movie
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: MOVIE,
WatchedDate: v.UserData.LastPlayedDate,
}, IMPORTED_WATCHED_JF)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
} else {
slog.Error("jellyfinSyncWatched: Movie failed to import.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (failed when adding to watched list): "+v.Name)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
if !v.UserData.LastPlayedDate.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
}
}
}
}
}
}
// Get played series
// Can't rely on IsPlayed filter, since we want to get partially played series too.
updateJobCurrentTask(jobId, userId, "syncing series")
allSeries := new(JellyfinItemSearchResponse)
err = jellyfinAPIRequest(
"GET",
"/Users/"+userThirdPartyId+"/Items",
map[string]string{
"IncludeItemTypes": "Series",
"Fields": "ProviderIds,RecursiveItemCount",
"Recursive": "true",
"IsPlaceHolder": "false",
},
username,
userThirdPartyAuth,
&allSeries,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
addJobError(jobId, userId, "failed to get jellyfin response for series")
} else {
if len(allSeries.Items) <= 0 {
slog.Info("jellyfinSyncWatched: No series found.", "user_id", userId)
} else {
// Import series
for _, v := range allSeries.Items {
slog.Info("jellyfinSyncWatched: Processing series.", "series_name", v.Name, "user_id", userId)
slog.Debug("jellyfinSyncWatched: Processing series.", "full_item", v, "user_id", userId)
// 1. Make sure show is watched or at least partially watched
if !v.UserData.Played && v.UserData.PlayedPercentage <= 0 && v.RecursiveItemCount == v.UserData.UnplayedItemCount {
slog.Debug("jellyfinSyncWatched: Skipping unwatched series:", "series_name", v.Name, "user_id", userId)
continue
}
// 1.1. Ensure we have a tmdbId
if v.ProviderIds.Tmdb == "" {
slog.Error("jellyfinSyncWatched: Series to import does not have a tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "series could not be imported (no tmdbId present): "+v.Name)
continue
}
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
if err != nil {
slog.Error("jellyfinSyncWatched: Series to import does not have a parseable (to int) tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "series could not be imported (tmdbId was not parseable): "+v.Name)
continue
}
updateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
// 2. Imported watched series
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: SHOW,
WatchedDate: v.UserData.LastPlayedDate,
}, IMPORTED_WATCHED_JF)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Info("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.",
"series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "watched_id", w.ID)
} else {
slog.Error("jellyfinSyncWatched: Series failed to import.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
addJobError(jobId, userId, "series could not be imported (failed when adding to watched list): "+v.Name)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
if !v.UserData.LastPlayedDate.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
}
}
}
// 4. Import watched seasons for this serie
// Get all show seasons (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
seriesSeasons := new(JellyfinSeriesSeasonsResponse)
err = jellyfinAPIRequest(
"GET",
"/Shows/"+v.Id+"/Seasons",
map[string]string{
"UserId": userThirdPartyId,
"Fields": "ProviderIds",
"IsPlaceHolder": "false",
},
username,
userThirdPartyAuth,
&seriesSeasons,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
} else if len(seriesSeasons.Items) <= 0 {
slog.Info("jellyfinSyncWatched: Series has no seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
} else {
for _, vs := range seriesSeasons.Items {
slog.Debug("jellyfinSyncWatched: Processing a season.", "full_item", v, "user_id", userId)
if !vs.UserData.Played {
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed season.", "series_name", v.Name, "season_num", vs.IndexNumber, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.IndexNumber,
Status: FINISHED,
addActivity: SEASON_ADDED_JF,
addActivityDate: vs.UserData.LastPlayedDate,
})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
}
}
}
// 5. Import watched episodes for this serie
// Gets all show episodes (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
seriesEpisodes := new(JellyfinSeriesEpisodesResponse)
err = jellyfinAPIRequest(
"GET",
"/Shows/"+v.Id+"/Episodes",
map[string]string{
"UserId": userThirdPartyId,
"Fields": "ProviderIds",
"IsPlaceHolder": "false",
},
username,
userThirdPartyAuth,
&seriesEpisodes,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
} else if len(seriesEpisodes.Items) <= 0 {
slog.Info("jellyfinSyncWatched: Series has no episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
} else {
for _, vs := range seriesEpisodes.Items {
slog.Debug("jellyfinSyncWatched: Processing an episode.", "full_item", v, "user_id", userId)
if !vs.UserData.Played {
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.ParentIndexNumber)+" episode "+strconv.Itoa(vs.IndexNumber))
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.ParentIndexNumber,
EpisodeNumber: vs.IndexNumber,
Status: FINISHED,
addActivity: EPISODE_ADDED_JF,
addActivityDate: vs.UserData.LastPlayedDate,
})
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
}
}
}
}
}
}
updateJobStatus(jobId, userId, JOB_DONE)
}
func jellyfinSyncWatched(
db *gorm.DB,
userId uint,
userType UserType,
username string,
userThirdPartyId string,
userThirdPartyAuth string,
) (JellyfinSyncResponse, error) {
jobId, err := addJob("jf_sync", userId)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to create a job", "error", err)
return JellyfinSyncResponse{}, errors.New("failed to create job")
}
updateJobStatus(jobId, userId, JOB_RUNNING)
go startJellyfinSync(
db,
jobId,
userId,
username,
userThirdPartyId,
userThirdPartyAuth,
)
return JellyfinSyncResponse{JobId: jobId}, nil
}
+125
View File
@@ -0,0 +1,125 @@
// We will use jobs only for storing/retrieving active job statuses for the client.
// Running the job will be done wherever needed, but isn't handled here.
// When starting a job elsewhere, we should first add a job here as active to get an `id`,
// this id should be used to update the active job so the client can request job status updates.
package main
import (
"errors"
"log/slog"
"time"
)
type JobStatus string
var (
JOB_CREATED JobStatus = "CREATED"
JOB_RUNNING JobStatus = "RUNNING"
JOB_DONE JobStatus = "DONE"
JOB_CANCELLED JobStatus = "CANCELLED"
)
type Job struct {
// We can give the job a name simply for showing on the client.
Name string `json:"name"`
// The current status of the job.
Status JobStatus `json:"status"`
// The current task we are performing inside the job.
// Just so we can portray progress on the client by displaying the current task.
CurrentTask string `json:"currentTask,omitempty"`
// Errors that occurred in the task
Errors []string `json:"errors"`
// Stored for access control.
UserId uint `json:"-"`
}
var activeJobs = make(map[string]*Job)
// Add a job to our activeJobs map.
// Returns id of job on success, or error if failed to add.
func addJob(name string, userId uint) (string, error) {
idk, err := generateString(8)
if err != nil {
return "", err
}
_, ok := activeJobs[idk]
if ok {
// Lets just hope this doesn't happen, may the odds be with us.
return "", errors.New("job already exists with id generated, please try again")
}
activeJobs[idk] = &Job{
Name: name,
Status: JOB_CREATED,
UserId: userId,
}
return idk, nil
}
func rmJob(id string, userId uint) {
slog.Debug("rmJob: Removing a job.", "id", id)
v, ok := activeJobs[id]
if ok && v.UserId == userId {
delete(activeJobs, id)
slog.Debug("rmJob: Removed a job.", "id", id)
return
}
slog.Debug("rmJob: Job to remove does not exist (or not owned by this user).", "id", id, "user_id", userId)
}
// Get a job.
// Returns job if found, otherwise errors if job does not exist.
func getJob(id string, userId uint) (*Job, error) {
j, ok := activeJobs[id]
if ok {
// Ensure user requesting a job, owns the job.
if j.UserId != userId {
slog.Warn("getJob: A user tried to access a job they do not own.", "user_id", userId, "job_id", id)
return &Job{}, errors.New("job does not exist")
}
return j, nil
}
return &Job{}, errors.New("job does not exist")
}
// Update a jobs status.
func updateJobStatus(id string, userId uint, status JobStatus) error {
j, err := getJob(id, userId)
if err != nil {
slog.Error("updateJobStatus: Failed!", "status", status, "error", err)
return err
}
j.Status = status
// If job is set to done, remove it after 1 minute.
if status == JOB_DONE {
slog.Debug("updateJobStatus: Job set to done. Will be removed after 1m.", "id", id)
go func() {
time.Sleep(1 * time.Minute)
slog.Debug("updateJobStatus: Job done. waited 1m.. removing job now.", "id", id)
rmJob(id, userId)
}()
}
return nil
}
// Update a jobs current task.
func updateJobCurrentTask(id string, userId uint, ct string) error {
j, err := getJob(id, userId)
if err != nil {
slog.Error("updateJobCurrentTask: Failed!", "ct", ct, "error", err)
return err
}
j.CurrentTask = ct
return nil
}
// Add an error to a job.
func addJobError(id string, userId uint, e string) error {
j, err := getJob(id, userId)
if err != nil {
slog.Error("updateJobCurrentTask: Failed!", "e", e, "error", err)
return err
}
j.Errors = append(j.Errors, e)
return nil
}
+676
View File
@@ -0,0 +1,676 @@
package main
import (
"encoding/json"
"encoding/xml"
"errors"
"io"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type PlexLoginRequest struct {
AuthToken string `json:"token" binding:"required"`
ClientIdentifier string `json:"clientIdentifier" binding:"required"`
}
type PlexUser struct {
Id uint64 `json:"id" binding:"required"`
Uuid string `json:"uuid" binding:"required"`
Email string `json:"email" binding:"required"`
Username string `json:"username" binding:"required"`
}
type PlexAccountResponse struct {
User PlexUser `json:"user" binding:"required"`
}
type PlexIdentity struct {
MediaContainer struct {
MachineIdentifier string `json:"machineIdentifier"`
} `json:"MediaContainer"`
}
type PlexHostConfigUpdateResponse struct {
PLEX_MACHINE_ID string
}
type PlexUsersResponse struct {
XMLName xml.Name `xml:"MediaContainer"`
Text string `xml:",chardata"`
FriendlyName string `xml:"friendlyName,attr"`
Identifier string `xml:"identifier,attr"`
MachineIdentifier string `xml:"machineIdentifier,attr"`
TotalSize string `xml:"totalSize,attr"`
Size string `xml:"size,attr"`
User []struct {
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
Title string `xml:"title,attr"`
Username string `xml:"username,attr"`
Email string `xml:"email,attr"`
RecommendationsPlaylistId string `xml:"recommendationsPlaylistId,attr"`
Thumb string `xml:"thumb,attr"`
Protected string `xml:"protected,attr"`
Home string `xml:"home,attr"`
AllowTuners string `xml:"allowTuners,attr"`
AllowSync string `xml:"allowSync,attr"`
AllowCameraUpload string `xml:"allowCameraUpload,attr"`
AllowChannels string `xml:"allowChannels,attr"`
AllowSubtitleAdmin string `xml:"allowSubtitleAdmin,attr"`
FilterAll string `xml:"filterAll,attr"`
FilterMovies string `xml:"filterMovies,attr"`
FilterMusic string `xml:"filterMusic,attr"`
FilterPhotos string `xml:"filterPhotos,attr"`
FilterTelevision string `xml:"filterTelevision,attr"`
Restricted string `xml:"restricted,attr"`
Server []struct {
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
ServerId string `xml:"serverId,attr"`
MachineIdentifier string `xml:"machineIdentifier,attr"`
Name string `xml:"name,attr"`
LastSeenAt string `xml:"lastSeenAt,attr"`
NumLibraries string `xml:"numLibraries,attr"`
AllLibraries string `xml:"allLibraries,attr"`
Owned string `xml:"owned,attr"`
Pending string `xml:"pending,attr"`
} `xml:"Server"`
} `xml:"User"`
}
// Plex get libraries response
// /library/sections
type PlexLibrariesResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Title1 string `json:"title1"`
Directory []struct {
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Composite string `json:"composite"`
Filters bool `json:"filters"`
Refreshing bool `json:"refreshing"`
Thumb string `json:"thumb"`
Key string `json:"key"`
Type string `json:"type"`
Title string `json:"title"`
Agent string `json:"agent"`
Scanner string `json:"scanner"`
Language string `json:"language"`
UUID string `json:"uuid"`
UpdatedAt int `json:"updatedAt"`
CreatedAt int `json:"createdAt"`
ScannedAt int `json:"scannedAt"`
Content bool `json:"content"`
Directory bool `json:"directory"`
ContentChangedAt int64 `json:"contentChangedAt"`
Hidden int `json:"hidden"`
Location []struct {
ID int `json:"id"`
Path string `json:"path"`
} `json:"Location"`
} `json:"Directory"`
} `json:"MediaContainer"`
}
// Plex get all library items response (with includeGuids parameter)
// /library/sections/{sectionId}/all
type PlexLibraryItemsResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Identifier string `json:"identifier"`
LibrarySectionID int `json:"librarySectionID"`
LibrarySectionTitle string `json:"librarySectionTitle"`
LibrarySectionUUID string `json:"librarySectionUUID"`
MediaTagPrefix string `json:"mediaTagPrefix"`
MediaTagVersion int `json:"mediaTagVersion"`
Thumb string `json:"thumb"`
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
ViewMode int `json:"viewMode"`
Metadata []struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
GUID string `json:"guid"` // Plex guid
Slug string `json:"slug"`
Studio string `json:"studio"`
Type string `json:"type"`
Title string `json:"title"`
ContentRating string `json:"contentRating"`
Summary string `json:"summary"`
Rating float64 `json:"rating"`
UserRating float64 `json:"userRating"`
AudienceRating float64 `json:"audienceRating"`
ViewCount int `json:"viewCount,omitempty"`
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
Year int `json:"year"`
Tagline string `json:"tagline"`
Thumb string `json:"thumb"`
Art string `json:"art"`
Duration int `json:"duration"`
OriginallyAvailableAt string `json:"originallyAvailableAt"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
AudienceRatingImage string `json:"audienceRatingImage"`
HasPremiumPrimaryExtra string `json:"hasPremiumPrimaryExtra"`
RatingImage string `json:"ratingImage"`
LeafCount int `json:"leafCount,omitempty"`
ViewedLeafCount int `json:"viewedLeafCount,omitempty"`
Media []struct {
ID int `json:"id"`
Duration int `json:"duration"`
Bitrate int `json:"bitrate"`
Width int `json:"width"`
Height int `json:"height"`
AspectRatio float64 `json:"aspectRatio"`
AudioChannels int `json:"audioChannels"`
AudioCodec string `json:"audioCodec"`
VideoCodec string `json:"videoCodec"`
VideoResolution string `json:"videoResolution"`
Container string `json:"container"`
VideoFrameRate string `json:"videoFrameRate"`
VideoProfile string `json:"videoProfile"`
Part []struct {
ID int `json:"id"`
Key string `json:"key"`
Duration int `json:"duration"`
File string `json:"file"`
Size int64 `json:"size"`
Container string `json:"container"`
HasThumbnail string `json:"hasThumbnail"`
VideoProfile string `json:"videoProfile"`
} `json:"Part"`
} `json:"Media"`
// External ids
Guid []struct {
ID string `json:"id"`
} `json:"Guid"`
Genre []struct {
Tag string `json:"tag"`
} `json:"Genre"`
Country []struct {
Tag string `json:"tag"`
} `json:"Country"`
Director []struct {
Tag string `json:"tag"`
} `json:"Director"`
Writer []struct {
Tag string `json:"tag"`
} `json:"Writer"`
Role []struct {
Tag string `json:"tag"`
} `json:"Role"`
ChapterSource string `json:"chapterSource,omitempty"`
} `json:"Metadata"`
} `json:"MediaContainer"`
}
// Plex get all children (seasons) for a serie.
// /library/metadata/{ratingKey}/children
type PlexLibraryItemSeasonsResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Identifier string `json:"identifier"`
Key string `json:"key"`
LibrarySectionID int `json:"librarySectionID"`
LibrarySectionTitle string `json:"librarySectionTitle"`
LibrarySectionUUID string `json:"librarySectionUUID"`
MediaTagPrefix string `json:"mediaTagPrefix"`
MediaTagVersion int `json:"mediaTagVersion"`
Nocache bool `json:"nocache"`
ParentIndex int `json:"parentIndex"`
ParentTitle string `json:"parentTitle"`
ParentYear int `json:"parentYear"`
Summary string `json:"summary"`
Theme string `json:"theme"`
Thumb string `json:"thumb"`
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
ViewMode int `json:"viewMode"`
Directory []struct {
LeafCount int `json:"leafCount"`
Thumb string `json:"thumb"`
ViewedLeafCount int `json:"viewedLeafCount"`
Key string `json:"key"`
Title string `json:"title"`
} `json:"Directory"`
Metadata []struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
ParentRatingKey string `json:"parentRatingKey"`
GUID string `json:"guid"`
ParentGUID string `json:"parentGuid"`
ParentSlug string `json:"parentSlug"`
ParentStudio string `json:"parentStudio"`
Type string `json:"type"`
Title string `json:"title"`
ParentKey string `json:"parentKey"`
ParentTitle string `json:"parentTitle"`
Summary string `json:"summary"`
Index int `json:"index"`
ParentIndex int `json:"parentIndex"`
UserRating float64 `json:"userRating,omitempty"`
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
ParentYear int `json:"parentYear"`
Thumb string `json:"thumb"`
Art string `json:"art"`
ParentThumb string `json:"parentThumb"`
ParentTheme string `json:"parentTheme"`
LeafCount int `json:"leafCount"`
ViewedLeafCount int `json:"viewedLeafCount"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
ViewCount int `json:"viewCount,omitempty"`
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
} `json:"Metadata"`
} `json:"MediaContainer"`
}
// Plex get all children (episodes) for a season.
// /library/metadata/{ratingKey}/allLeaves
type PlexLibraryItemEpisodesResponse struct {
MediaContainer struct {
Size int `json:"size"`
AllowSync bool `json:"allowSync"`
Art string `json:"art"`
Identifier string `json:"identifier"`
Key string `json:"key"`
LibrarySectionID int `json:"librarySectionID"`
LibrarySectionTitle string `json:"librarySectionTitle"`
LibrarySectionUUID string `json:"librarySectionUUID"`
MediaTagPrefix string `json:"mediaTagPrefix"`
MediaTagVersion int `json:"mediaTagVersion"`
MixedParents bool `json:"mixedParents"`
Nocache bool `json:"nocache"`
ParentIndex int `json:"parentIndex"`
ParentTitle string `json:"parentTitle"`
ParentYear int `json:"parentYear"`
Theme string `json:"theme"`
Title1 string `json:"title1"`
Title2 string `json:"title2"`
ViewGroup string `json:"viewGroup"`
ViewMode int `json:"viewMode"`
Metadata []struct {
RatingKey string `json:"ratingKey"`
Key string `json:"key"`
ParentRatingKey string `json:"parentRatingKey"`
GrandparentRatingKey string `json:"grandparentRatingKey"`
GUID string `json:"guid"`
GrandparentSlug string `json:"grandparentSlug"`
Studio string `json:"studio"`
Type string `json:"type"`
Title string `json:"title"`
GrandparentKey string `json:"grandparentKey"`
ParentKey string `json:"parentKey"`
GrandparentTitle string `json:"grandparentTitle"`
ParentTitle string `json:"parentTitle"`
ContentRating string `json:"contentRating"`
Summary string `json:"summary"`
Index int `json:"index"`
ParentIndex int `json:"parentIndex"`
AudienceRating float64 `json:"audienceRating"`
Year int `json:"year"`
Thumb string `json:"thumb"`
Art string `json:"art"`
ParentThumb string `json:"parentThumb"`
GrandparentThumb string `json:"grandparentThumb"`
GrandparentArt string `json:"grandparentArt"`
GrandparentTheme string `json:"grandparentTheme"`
Duration int `json:"duration"`
OriginallyAvailableAt string `json:"originallyAvailableAt"`
AddedAt int `json:"addedAt"`
UpdatedAt int `json:"updatedAt"`
AudienceRatingImage string `json:"audienceRatingImage"`
ChapterSource string `json:"chapterSource"`
TitleSort string `json:"titleSort,omitempty"`
UserRating float64 `json:"userRating,omitempty"`
ViewCount int `json:"viewCount,omitempty"`
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
} `json:"Metadata"`
} `json:"MediaContainer"`
}
// Response from clients.plex.tv/api/v2/resources
// Used to get users auth token for home plex server.
type PlexClientResources []struct {
Name string `json:"name"`
Product string `json:"product"`
ProductVersion string `json:"productVersion"`
Platform string `json:"platform"`
PlatformVersion string `json:"platformVersion"`
Device string `json:"device"`
ClientIdentifier string `json:"clientIdentifier"`
CreatedAt time.Time `json:"createdAt"`
LastSeenAt time.Time `json:"lastSeenAt"`
Provides string `json:"provides"`
OwnerID interface{} `json:"ownerId"`
SourceTitle interface{} `json:"sourceTitle"`
PublicAddress string `json:"publicAddress"`
AccessToken string `json:"accessToken"`
Owned bool `json:"owned"`
Home bool `json:"home"`
Synced bool `json:"synced"`
Relay bool `json:"relay"`
Presence bool `json:"presence"`
HTTPSRequired bool `json:"httpsRequired"`
PublicAddressMatches bool `json:"publicAddressMatches"`
DNSRebindingProtection bool `json:"dnsRebindingProtection"`
NatLoopbackSupported bool `json:"natLoopbackSupported"`
Connections []struct {
Protocol string `json:"protocol"`
Address string `json:"address"`
Port int `json:"port"`
URI string `json:"uri"`
Local bool `json:"local"`
Relay bool `json:"relay"`
IPv6 bool `json:"IPv6"`
} `json:"connections"`
}
// Plex access middleware, ensures user is a Plex user.
// To be ran after AuthRequired middleware with extra data.
func PlexAccessRequired(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
userType := c.MustGet("userType").(UserType)
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
c.AbortWithStatus(401)
return
}
if userType != PLEX_USER {
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
c.AbortWithStatus(401)
return
}
userPlexService := new(UserServices)
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
c.AbortWithStatus(401)
return
}
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
c.AbortWithStatus(401)
return
}
c.Set("plexAuthToken", userPlexService.AuthToken)
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
}
}
func getPlexIdentity(host string) (PlexIdentity, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", host+"/identity", nil)
if err != nil {
return PlexIdentity{}, err
}
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return PlexIdentity{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexIdentity{}, err
}
defer resp.Body.Close()
var pi PlexIdentity
err = json.Unmarshal(body, &pi)
if err != nil {
return PlexIdentity{}, err
}
return pi, nil
}
func fetchPlexAccountFromToken(token string) (PlexUser, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
if err != nil {
return PlexUser{}, err
}
req.Header.Set("X-Plex-Token", token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := httpClient.Do(req)
if err != nil {
return PlexUser{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexUser{}, err
}
slog.Info(string(body))
defer resp.Body.Close()
var pa PlexAccountResponse
err = json.Unmarshal(body, &pa)
if err != nil {
return PlexUser{}, err
}
return pa.User, nil
}
// Update plex host setting
func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
Config.PLEX_HOST = v
if Config.PLEX_HOST != "" {
pi, err := getPlexIdentity(Config.PLEX_HOST)
if err != nil {
slog.Error("updateConfigPlexHost: Failed to get plex server identity!", "error", err)
return PlexHostConfigUpdateResponse{}, errors.New("failed to get Plex server identity. Please try setting the Plex Host again or setting PLEX_MACHINE_ID manually in your config file")
}
if pi.MediaContainer.MachineIdentifier == "" {
slog.Error("updateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
return PlexHostConfigUpdateResponse{}, errors.New("got Plex server identity, but no machine id was found")
}
Config.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
} else {
Config.PLEX_MACHINE_ID = ""
}
if err := writeConfig(); err != nil {
slog.Error("updateConfigPlexHost: Failed to write updated config to file!", "err", err)
return PlexHostConfigUpdateResponse{}, errors.New("failed to write config")
}
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
}
// If a plex user has access to our home plex server (PLEX_HOST).
func plexUserHasAccessToPlexHost(token string) error {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", "https://plex.tv/api/users", nil)
if err != nil {
return err
}
req.Header.Set("X-Plex-Token", token)
resp, err := httpClient.Do(req)
if err != nil {
return err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
defer resp.Body.Close()
var pa PlexUsersResponse
err = xml.Unmarshal(body, &pa)
if err != nil {
return err
}
if len(pa.User) <= 0 {
return errors.New("found no users in response")
}
// Now check if any of the users servers include our home server machine id
homeServerFound := false
userLoop:
for _, user := range pa.User {
for _, server := range user.Server {
if server.MachineIdentifier == Config.PLEX_MACHINE_ID {
slog.Debug("plexUserHasAccessToPlexHost: Processing a server.", "server", server.MachineIdentifier)
homeServerFound = true
break userLoop
}
}
if homeServerFound {
break
}
}
if homeServerFound {
return nil
}
return errors.New("user does not have access to home plex server")
}
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
if err != nil {
return PlexLibrariesResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibrariesResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibrariesResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibrariesResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibrariesResponse{}, err
}
return pl, nil
}
func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibraryItemsResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibraryItemsResponse{}, err
}
return pl, nil
}
func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibraryItemSeasonsResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibraryItemSeasonsResponse{}, err
}
return pl, nil
}
func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
resp, err := httpClient.Do(req)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
defer resp.Body.Close()
var pl PlexLibraryItemEpisodesResponse
err = json.Unmarshal(body, &pl)
if err != nil {
return PlexLibraryItemEpisodesResponse{}, err
}
return pl, nil
}
// Gets users auth token for local plex server,
// so they can authenticate against it for api requests.
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
httpClient := &http.Client{}
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
if err != nil {
return "", err
}
req.Header.Add("Accept", "application/json")
req.Header.Set("X-Plex-Token", plexAuth)
req.Header.Set("X-Plex-Client-Identifier", userClientId)
resp, err := httpClient.Do(req)
if err != nil {
return "", err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
defer resp.Body.Close()
var pl PlexClientResources
err = json.Unmarshal(body, &pl)
if err != nil {
return "", err
}
authToken := ""
for _, v := range pl {
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
if v.AccessToken == "" {
slog.Warn("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
continue
}
authToken = v.AccessToken
}
}
return authToken, nil
}
+267
View File
@@ -0,0 +1,267 @@
package main
import (
"errors"
"log/slog"
"strconv"
"strings"
"time"
"gorm.io/gorm"
)
type PlexSyncResponse struct {
JobId string `json:"jobId"`
}
// Perform a Plex sync.
// Errors are added silently to the job.
func startPlexSync(
db *gorm.DB,
jobId string,
userId uint,
userPlexLocalAuth string,
) {
updateJobCurrentTask(jobId, userId, "fetching libraries")
libraries, err := getPlexLibraries(userPlexLocalAuth)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
addJobError(jobId, userId, "failed to get plex libraries")
updateJobStatus(jobId, userId, JOB_DONE)
return
}
for _, library := range libraries.MediaContainer.Directory {
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
if library.Type == "movie" {
updateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
movies, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
addJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
continue
}
for _, movie := range movies.MediaContainer.Metadata {
if movie.ViewCount == 0 {
// Not viewed and not rated, skip importing
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
// Find tmdb id
if len(movie.Guid) <= 0 {
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
continue
}
tmdbIdStr := ""
for _, v := range movie.Guid {
if strings.HasPrefix(v.ID, "tmdb://") {
tmdbIdStr = v.ID[7:]
break
}
}
if tmdbIdStr == "" {
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
continue
}
tmdbId, err := strconv.Atoi(tmdbIdStr)
if err != nil {
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
continue
}
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: MOVIE,
Rating: int8(movie.UserRating),
WatchedDate: lastViewedAt,
}, IMPORTED_WATCHED_PLEX)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("plexSyncWatched: unique constraint hit. movie must already be on watch list", "error", err)
continue
}
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
addJobError(jobId, userId, "failed to add movie "+movie.Title)
} else {
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
if !lastViewedAt.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{
WatchedID: w.ID,
Type: IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
}
}
}
}
} else if library.Type == "show" {
updateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
shows, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
addJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
continue
}
for _, show := range shows.MediaContainer.Metadata {
if show.ViewedLeafCount != show.LeafCount {
// Not viewed, skip importing
// (could be improved to set status as watching when viewedLeafCount is higher than 0)
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "importing show "+show.Title)
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
tmdbIdStr := ""
for _, v := range show.Guid {
if strings.HasPrefix(v.ID, "tmdb://") {
tmdbIdStr = v.ID[7:]
break
}
}
if tmdbIdStr == "" {
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
continue
}
tmdbId, err := strconv.Atoi(tmdbIdStr)
if err != nil {
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
addJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
continue
}
lastViewedAt := time.Unix(show.LastViewedAt, 0)
w, err := addWatched(db, userId, WatchedAddRequest{
Status: FINISHED,
ContentID: tmdbId,
ContentType: SHOW,
Rating: int8(show.UserRating),
WatchedDate: lastViewedAt,
}, IMPORTED_WATCHED_PLEX)
if err != nil {
if err.Error() == "content already on watched list" {
slog.Info("plexSyncWatched: unique constraint hit. show must already be on watch list", "error", err)
} else {
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
addJobError(jobId, userId, "failed to add show "+show.Title)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
if !lastViewedAt.IsZero() {
_, err := addActivity(db, userId, ActivityAddRequest{
WatchedID: w.ID,
Type: IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
}
}
}
// Import watched seasons for this serie
seriesSeasons, err := getPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
} else {
for _, vs := range seriesSeasons.MediaContainer.Metadata {
slog.Debug("plexSyncWatched: Processing a season.", "full_item", vs, "user_id", userId)
if vs.ViewedLeafCount != vs.LeafCount {
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
var seasonLastViewedAt time.Time
if vs.LastViewedAt != 0 {
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
}
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.Index,
Status: FINISHED,
addActivity: SEASON_ADDED_PLEX,
addActivityDate: seasonLastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
}
}
}
// Import watched episodes for this serie
seriesEpisodes, err := getPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
if err != nil {
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
} else {
for _, vs := range seriesEpisodes.MediaContainer.Metadata {
slog.Debug("plexSyncWatched: Processing an episode.", "full_item", vs, "user_id", userId)
if vs.ViewCount <= 0 {
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
continue
}
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
var episodeLastViewedAt time.Time
if vs.LastViewedAt != 0 {
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
}
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: vs.ParentIndex,
EpisodeNumber: vs.Index,
Status: FINISHED,
addActivity: EPISODE_ADDED_PLEX,
addActivityDate: episodeLastViewedAt,
})
if err != nil {
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
}
}
}
}
}
}
updateJobStatus(jobId, userId, JOB_DONE)
}
func plexSyncWatched(
db *gorm.DB,
userId uint,
userPlexLocalAuth string,
) (PlexSyncResponse, error) {
jobId, err := addJob("plex_sync", userId)
if err != nil {
slog.Error("startPlexSync: Failed to create a job", "error", err)
return PlexSyncResponse{}, errors.New("failed to create job")
}
updateJobStatus(jobId, userId, JOB_RUNNING)
go startPlexSync(
db,
jobId,
userId,
userPlexLocalAuth,
)
return PlexSyncResponse{JobId: jobId}, nil
}
+121 -1
View File
@@ -18,6 +18,10 @@ type ErrorResponse struct {
Error string `json:"error"`
}
type ValueRequest struct {
Value any `json:"value"`
}
type KeyValueRequest struct {
Key string `json:"key"`
Value any `json:"value"`
@@ -497,6 +501,43 @@ func (b *BaseRouter) addActivityRoutes() {
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
activity.PUT(":id", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Status(400)
return
}
var activityUpdateRequest ActivityUpdateRequest
err = c.ShouldBindJSON(&activityUpdateRequest)
if err == nil {
err = updateActivity(b.db, userId, uint(id), activityUpdateRequest)
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()})
})
activity.DELETE(":id", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil {
c.Status(400)
slog.Error("Could not process activity id when attempting a deletion", "error", err.Error(), "id", c.Param("id"))
return
}
err = deleteActivity(b.db, userId, uint(id))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.Status(http.StatusOK)
})
}
func (b *BaseRouter) addAuthRoutes() {
@@ -532,6 +573,21 @@ func (b *BaseRouter) addAuthRoutes() {
c.Status(400)
})
// Plex login
auth.POST("/plex", func(c *gin.Context) {
var plexRequest PlexLoginRequest
if c.ShouldBindJSON(&plexRequest) == nil {
response, err := loginPlex(&plexRequest, b.db)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
return
}
c.Status(400)
})
// Register
auth.POST("/register", func(c *gin.Context) {
var user UserRegisterRequest
@@ -553,6 +609,9 @@ func (b *BaseRouter) addAuthRoutes() {
if Config.JELLYFIN_HOST != "" {
availableAuthProviders = append(availableAuthProviders, "jellyfin")
}
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
availableAuthProviders = append(availableAuthProviders, "plex")
}
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
AvailableAuthProviders: availableAuthProviders,
SignupEnabled: Config.SIGNUP_ENABLED,
@@ -626,7 +685,7 @@ func (b *BaseRouter) addProfileRoutes() {
}
func (b *BaseRouter) addJellyfinRoutes() {
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db))
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db), JellyfinAccessRequired())
// Check if jf has item
jf.GET("/:type/:name/:tmdbId", func(c *gin.Context) {
@@ -642,6 +701,37 @@ func (b *BaseRouter) addJellyfinRoutes() {
}
c.JSON(http.StatusOK, response)
})
// Sync users jellyfin watched items to watchlist
jf.GET("/sync", 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 := jellyfinSyncWatched(b.db, userId, userType, username, userThirdPartyId, userThirdPartyAuth)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addPlexRoutes() {
plex := b.rg.Group("/plex").Use(AuthRequired(b.db), PlexAccessRequired(b.db))
// Sync users plex watched items to watchlist
plex.GET("/sync", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
userPlexLocalAuth := c.MustGet("plexLocalAuthToken").(string)
response, err := plexSyncWatched(b.db, userId, userPlexLocalAuth)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
})
}
func (b *BaseRouter) addUserRoutes() {
@@ -851,6 +941,22 @@ func (b *BaseRouter) addServerRoutes() {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
// Update plex host config
server.POST("/config/plex_host", func(c *gin.Context) {
var ur ValueRequest
err := c.ShouldBindJSON(&ur)
if err == nil {
resp, err := updateConfigPlexHost(ur.Value.(string))
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, resp)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
// Get server stats
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
c.JSON(http.StatusOK, getServerStats(b.db))
@@ -1072,3 +1178,17 @@ func (b *BaseRouter) addRadarrRoutes() {
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
})
}
func (b *BaseRouter) addJobRoutes() {
job := b.rg.Group("/job").Use(AuthRequired(nil))
job.GET("/:id", func(c *gin.Context) {
userId := c.MustGet("userId").(uint)
response, err := getJob(c.Param("id"), userId)
if err != nil {
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, *response)
})
}
+3
View File
@@ -71,6 +71,7 @@ func main() {
err = db.AutoMigrate(
&User{},
&UserServices{},
&Content{},
&Watched{},
&WatchedSeason{},
@@ -131,6 +132,7 @@ func main() {
br.addActivityRoutes()
br.addProfileRoutes()
br.addJellyfinRoutes()
br.addPlexRoutes()
br.addUserRoutes()
br.addFollowRoutes()
br.addImportRoutes()
@@ -138,6 +140,7 @@ func main() {
br.addFeatureRoutes()
br.addSonarrRoutes()
br.addRadarrRoutes()
br.addJobRoutes()
br.rg.Static("/img", path.Join(DataPath, "img"))
go setupTasks(db)
+2 -1
View File
@@ -163,6 +163,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
}
// If custom WatchedDate passed, set CreatedAt and UpdatedAt fields to it.
if !ar.WatchedDate.IsZero() {
slog.Debug("Adding watched item: The provided WatchedDate is valid.", "watched_date", ar.WatchedDate, "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
watched.CreatedAt = ar.WatchedDate
watched.UpdatedAt = ar.WatchedDate
}
@@ -174,7 +175,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
}
if watched.DeletedAt.Time.IsZero() {
return Watched{}, errors.New("content already on watched list")
return watched, errors.New("content already on watched list")
} else {
slog.Info("addWatched: Watched list item for this content exists as soft deleted record.. attempting to restore")
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
+33 -16
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"log/slog"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -26,11 +27,13 @@ type WatchedEpisode struct {
}
type WatchedEpisodeAddRequest struct {
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
}
type WatchedEpisodeAddResponse struct {
@@ -54,16 +57,19 @@ func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (
if w.Content.Type != SHOW {
return WatchedEpisodeAddResponse{}, errors.New("can't add watched episode for non show content")
}
var found bool
found := false
updated := false
for i, we := range w.WatchedEpisodes {
if we.SeasonNumber == ar.SeasonNumber && we.EpisodeNumber == ar.EpisodeNumber {
slog.Debug("Existing watched episode item found, updating existing")
found = true
if ar.Status != "" {
if ar.Status != "" && ar.Status != w.WatchedEpisodes[i].Status {
w.WatchedEpisodes[i].Status = ar.Status
updated = true
}
if ar.Rating != 0 {
if ar.Rating != 0 && ar.Rating != w.WatchedEpisodes[i].Rating {
w.WatchedEpisodes[i].Rating = ar.Rating
updated = true
}
break
}
@@ -86,17 +92,28 @@ func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (
}
// Add activity
if found {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_RATING_CHANGED, Data: string(json)})
// Only add change activity if we actually updated a value
// (changing value to same value doesn't count).
if updated {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_RATING_CHANGED, Data: string(json)})
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_ADDED, Data: string(json)})
act := ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_ADDED, Data: string(json)}
if ar.addActivity != "" {
act.Type = ar.addActivity
}
if !ar.addActivityDate.IsZero() {
act.CustomDate = &ar.addActivityDate
}
addedActivity, _ = addActivity(db, userId, act)
}
return WatchedEpisodeAddResponse{
WatchedEpisodes: w.WatchedEpisodes,
+32 -15
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"log/slog"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -21,10 +22,12 @@ type WatchedSeason struct {
}
type WatchedSeasonAddRequest struct {
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
WatchedID uint `json:"watchedId"`
SeasonNumber int `json:"seasonNumber"`
Status WatchedStatus `json:"status"`
Rating int8 `json:"rating"`
addActivity ActivityType `json:"-"`
addActivityDate time.Time `json:"-"`
}
type WatchedSeasonAddResponse struct {
@@ -48,16 +51,19 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
if w.Content.Type != SHOW {
return WatchedSeasonAddResponse{}, errors.New("can't add watched season for non show content")
}
var found bool
found := false
updated := false
for i, ws := range w.WatchedSeasons {
if ws.SeasonNumber == ar.SeasonNumber {
slog.Debug("Existing watched season item found, updating existing")
found = true
if ar.Status != "" {
if ar.Status != "" && ar.Status != w.WatchedSeasons[i].Status {
w.WatchedSeasons[i].Status = ar.Status
updated = true
}
if ar.Rating != 0 {
if ar.Rating != 0 && ar.Rating != w.WatchedSeasons[i].Rating {
w.WatchedSeasons[i].Rating = ar.Rating
updated = true
}
break
}
@@ -79,17 +85,28 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
}
// Add activity
if found {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
// Only add change activity if we actually updated a value
// (changing value to same value doesn't count).
if updated {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)})
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
if ar.addActivity != "" {
act.Type = ar.addActivity
}
if !ar.addActivityDate.IsZero() {
act.CustomDate = &ar.addActivityDate
}
addedActivity, _ = addActivity(db, userId, act)
}
return WatchedSeasonAddResponse{
WatchedSeasons: w.WatchedSeasons,
+92 -41
View File
@@ -1,10 +1,14 @@
<script lang="ts">
import type { Activity } from "@/types";
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
import ActivityEditor from "./ActivityEditor.svelte";
export let activity: Activity[] | undefined;
export let wListId: number;
let clickedActivity: Activity;
let groupedActivities: { [index: string]: any };
let isActivityEditorVisible: boolean;
$: {
groupedActivities = getGroupedActivity(activity);
@@ -15,94 +19,123 @@
case "ADDED_WATCHED":
if (a.data) {
const data = JSON.parse(a.data);
return `added to watched list${data?.status ? ` as ${data.status?.toLowerCase()}` : ""}${
data?.rating ? ` with ${data.rating} stars` : ""
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
data?.rating ? ` with ${data.rating} Stars` : ""
}`;
}
return "added to watched list";
return "Added to Watched List";
case "REMOVED_WATCHED":
return "removed from watched list";
return "Removed from Watched List";
case "RATING_CHANGED":
if (a.data) {
return `rating changed to ${a.data}`;
return `Rating Changed to ${a.data}`;
}
return "rating changed";
return "Rating Changed";
case "STATUS_CHANGED":
if (a.data) {
return `status changed to ${a.data?.toLowerCase()}`;
return `Status Changed to ${toFullTitleCase(a.data)}`;
}
return "status changed";
return "Status Changed";
case "THOUGHTS_CHANGED":
return "thoughts changed";
return "Thoughts Changed";
case "THOUGHTS_REMOVED":
return "thoughts removed";
return "Thoughts Removed";
case "IMPORTED_WATCHED":
return "imported";
return "Imported";
case "IMPORTED_WATCHED_JF":
case "IMPORTED_WATCHED_PLEX":
return "Synced";
case "IMPORTED_RATING":
if (a.data) {
const data = JSON.parse(a.data);
if (data.rating) {
return `rating changed to ${data.rating}`;
return `Rating Changed to ${data.rating}`;
} else {
return "added to watchlist with no rating";
return "Added to Watchlist with No Rating";
}
}
return "imported rating";
return "Imported Rating";
case "IMPORTED_ADDED_WATCHED":
case "IMPORTED_ADDED_WATCHED_JF":
case "IMPORTED_ADDED_WATCHED_PLEX":
return "Imported Watch Date";
case "SEASON_ADDED":
if (a.data) {
const data = JSON.parse(a.data);
return `season ${data.season} added as ${data.status?.toLowerCase()}`;
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
}
return "season added";
return "Season Added";
case "SEASON_ADDED_JF":
case "SEASON_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
}
return "Season Synced";
case "SEASON_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `changed season ${data.season} rating to ${data.rating}`;
return `Changed Season ${data.season} Rating to ${data.rating}`;
}
return "season rating changed";
return "Season Rating Changed";
case "SEASON_STATUS_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `changed season ${data.season} status to ${data.status?.toLowerCase()}`;
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
}
return "season status changed";
return "Season Status Changed";
case "SEASON_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `season ${data.season} status removed`;
return `Season ${data.season} Status Removed`;
}
return "season removed";
return "Season Removed";
case "EPISODE_ADDED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} added ${data.status ? `as ${data.status?.toLowerCase()}` : data.rating ? `with rating ${data.rating}` : ""}`;
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "episode added";
return "Episode Added";
case "EPISODE_ADDED_JF":
case "EPISODE_ADDED_PLEX":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
}
return "Episode Synced";
case "EPISODE_RATING_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} rating changed to ${data.rating}`;
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
}
return "episode rating changed";
return "Episode Rating Changed";
case "EPISODE_STATUS_CHANGED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} status changed to ${data.status?.toLowerCase()}`;
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
}
return "episode status changed";
return "Episode Status Changed";
case "EPISODE_REMOVED":
if (a.data) {
const data = JSON.parse(a.data);
return `${seasonAndEpToReadable(data.season, data.episode)} removed`;
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
}
return "season removed";
return "Episode Removed";
default:
return a.type;
}
}
function toFullTitleCase(text: string | undefined) {
if (text) {
return text
.split(" ")
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
.join(" ");
}
return "Unknown";
}
function toDayTime(d: Date) {
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
}
@@ -133,8 +166,23 @@
}
return grouped;
}
function openEditor(a: Activity) {
clickedActivity = a;
isActivityEditorVisible = true;
return;
}
</script>
{#if isActivityEditorVisible}
<ActivityEditor
watchedId={wListId}
activity={clickedActivity}
activityMessage={getMsg(clickedActivity)}
onClose={() => (isActivityEditorVisible = false)}
/>
{/if}
<div class="activity">
<h2>Activity</h2>
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
@@ -145,10 +193,10 @@
{#each groupedActivities[k] as a}
{@const d = new Date(getCreatedAtVis(a))}
<li>
<span title={d.toDateString()}>
{toDayTime(d)}
</span>
<span>{getMsg(a)}</span>
<button class="unset" on:click={() => openEditor(a)}>
<span title={d.toDateString()}>{toDayTime(d)}</span>
<span>{getMsg(a)}</span>
</button>
</li>
{/each}
{/each}
@@ -187,12 +235,16 @@
}
li {
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
button {
all: unset;
display: flex;
flex-flow: row;
align-items: center;
gap: 8px;
width: max-content;
max-width: 100%;
cursor: pointer;
}
span {
width: max-content;
@@ -204,7 +256,6 @@
&:last-child {
background-color: $accent-color;
text-transform: capitalize;
color: $text-color;
border-radius: 8px;
padding: 10px 12px;
+131
View File
@@ -0,0 +1,131 @@
<script lang="ts">
import { updateActivity, removeActivity } from "@/lib/util/api";
import Modal from "./Modal.svelte";
import type { Activity } from "@/types";
import { notify } from "./util/notify";
export let watchedId: number;
export let activity: Activity;
export let activityMessage: string;
export let onClose: () => void;
let isDateTimeChanged: boolean;
let isDateTimeValid = true;
let currentDateObject = new Date(Date.parse(activity.customDate ?? activity.createdAt));
let currentDateString = dateToInputDateString(currentDateObject);
let currentTimeString = dateToInputTimeString(currentDateObject);
let selectedDateString = currentDateString;
let selectedTimeString = currentTimeString;
$: {
isDateTimeChanged =
currentDateString != selectedDateString || currentTimeString != selectedTimeString;
}
function dateToInputDateString(date: Date) {
const year = date.getFullYear();
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}-${month}-${day}`;
}
function dateToInputTimeString(date: Date) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
function validateNewDate() {
try {
const epochMillis = Date.parse(`${selectedDateString} ${selectedTimeString}`);
const dateObj = new Date(epochMillis);
if (isNaN(dateObj.getTime())) {
isDateTimeValid = false;
return;
}
isDateTimeValid = true;
return dateObj;
} catch (err) {
console.error("ActivityEditor: validateNewDate failed!", err);
isDateTimeValid = false;
}
}
function update() {
const dateObj = validateNewDate();
if (dateObj && isDateTimeValid && isDateTimeChanged) {
updateActivity(watchedId, activity.id, dateObj);
onClose();
return;
}
notify({ text: "Unable to try updating!", type: "error" });
console.error(
"ActivityEditor: Can't try updating, data missing/invalid:",
dateObj,
isDateTimeValid,
isDateTimeChanged
);
}
function remove() {
removeActivity(watchedId, activity.id);
onClose();
}
</script>
<Modal title="Edit Activity" desc={activityMessage} maxWidth="400px" {onClose}>
<div class="centered">
<h3>Date</h3>
<input
id="activity-date"
type="date"
bind:value={selectedDateString}
on:change={validateNewDate}
class:invalid={!isDateTimeValid}
/>
<h3>Time</h3>
<input
id="activity-time"
type="time"
bind:value={selectedTimeString}
on:change={validateNewDate}
/>
<div class="button-row">
<button class="danger" on:click={remove}>Delete</button>
<div>
<button on:click={update} disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button>
</div>
</div>
</div>
</Modal>
<style lang="scss">
.centered {
display: flex;
flex-flow: column;
gap: 10px;
height: 100%;
h3 {
font-size: 16px;
font-family:
sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont;
}
.button-row {
display: flex;
flex-flow: row;
justify-content: space-between;
margin-top: 10px;
button {
margin-top: auto;
width: max-content;
}
}
}
</style>
+4
View File
@@ -58,6 +58,10 @@
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
/>
</svg>
{:else if i === "plex"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
</svg>
{:else if i === "trash"}
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
<path
+2 -2
View File
@@ -4,11 +4,12 @@
export let title: string;
export let desc: string | undefined = undefined;
export let onClose: (() => void) | undefined = undefined;
export let maxWidth = "1000px";
</script>
<div class="backdrop"></div>
<div class="modal">
<div>
<div style="max-width:{maxWidth};">
{#if typeof onClose !== "undefined"}
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
{/if}
@@ -50,7 +51,6 @@
flex-flow: column;
min-width: 300px;
width: 100%;
max-width: 1000px;
max-height: calc(100% - 20px);
background-color: $bg-color;
border-radius: 10px;
+1
View File
@@ -62,6 +62,7 @@
button {
font-size: 10px;
padding: 5px 10px;
}
}
</style>
+51 -1
View File
@@ -10,7 +10,8 @@ import {
type WatchedUpdateResponse,
type UserSettings,
type Follow,
type PlayedAddRequest
type PlayedAddRequest,
type ActivityUpdateRequest
} from "@/types";
import axios from "axios";
import { get } from "svelte/store";
@@ -166,6 +167,55 @@ export function updatePlayed(
});
}
export function updateActivity(watchedId: number, activityId: number, date: Date) {
const nid = notify({ text: "Updating", type: "loading" });
console.debug("updateActivity called", watchedId, activityId, date);
try {
axios
.put("/activity/" + activityId, {
customDate: date.toISOString()
} as ActivityUpdateRequest)
.then((resp) => {
console.log("Updated activity timestamp:", resp.status);
const wList = get(watchedList);
const activity = wList
.find((w) => w.id === watchedId)
?.activity.find((a) => a.id === activityId);
if (activity) {
activity.customDate = date.toISOString();
watchedList.update(() => wList);
}
notify({ id: nid, text: "Updated!", type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed to Update!", type: "error" });
});
} catch (err) {
console.error("updateActivity failed!", err);
notify({ id: nid, text: "Failed!", type: "error" });
}
}
export function removeActivity(watchedId: number, activityId: number) {
const nid = notify({ text: "Deleting", type: "loading" });
axios
.delete("/activity/" + activityId)
.then((resp) => {
const wList = get(watchedList);
const wListItem = wList.find((w) => w.id === watchedId);
if (wListItem) {
wListItem.activity = wListItem.activity.filter((i) => i.id !== activityId);
watchedList.update(() => wList);
}
notify({ id: nid, text: "Deleted!", type: "success" });
})
.catch((err) => {
console.error(err);
notify({ id: nid, text: "Failed to Delete!", type: "error" });
});
}
export async function contentExistsOnJellyfin(
type: MediaType,
name: string,
+3 -3
View File
@@ -233,8 +233,8 @@ export function userHasPermission(perms: UserPermission, reqPerm: UserPermission
/**
* Takes season and episode number to make a quickly understandable
* string in this format S01E01.
* string in this format S1E1.
*/
export function seasonAndEpToReadable(season: number, episode: number) {
return `S${season ? String(season)?.padStart(2, "0") : "(unknown)"}E${episode ? String(episode)?.padStart(2, "0") : "(unknown)"}`;
export function seasonAndEpToReadable(season: number | undefined, episode: number | undefined) {
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
}
+120
View File
@@ -0,0 +1,120 @@
import { noAuthAxios } from "./api";
interface Plex {
win: Window;
headers: Record<string, string>;
clientId: string;
}
interface PlexPin {
id: number;
code: string;
}
//
function uuidv4() {
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (c: any) =>
(c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16)
);
}
export function preparePlexAuth(): Plex {
// No point making a store for this.
let clientId = localStorage.getItem("plex-cid");
if (!clientId) {
if (crypto.randomUUID) {
console.log("preparePlexAuth: Using randomUUID");
clientId = crypto.randomUUID();
} else {
// Use this if randomUUID is unavailable (ex in unsecure context, this use case isn't as big a deelio)
console.log("preparePlexAuth: Not using randomUUID");
clientId = uuidv4();
}
localStorage.setItem("plex-cid", clientId);
}
const w = 600;
const h = 800;
// Fixes dual-screen position Most browsers Firefox
const dualScreenLeft = window.screenLeft != undefined ? window.screenLeft : window.screenX;
const dualScreenTop = window.screenTop != undefined ? window.screenTop : window.screenY;
const width = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width;
const height = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height;
const left = width / 2 - w / 2 + dualScreenLeft;
const top = height / 2 - h / 2 + dualScreenTop;
const win = window.open(
"",
"Continue to Watcharr With Plex",
`scrollbars=yes, width=${w}, height=${h}, top=${top}, left=${left}`
);
if (win) {
win.focus();
return {
win,
// Don't really want to give all the possible headers,
// trying to minimize it to what gets it working.
headers: {
"X-Plex-Product": "Watcharr",
"X-Plex-Client-Identifier": clientId,
"X-Plex-Version": "Plex OAuth",
"X-Plex-Model": "Plex OAuth",
"X-Plex-Platform": "Firefox",
"X-Plex-Platform-Version": "123.0",
"X-Plex-Device": "Windows",
"X-Plex-Device-Name": "Watcharr"
},
clientId
};
}
throw new Error("Failed to prepare popup!");
}
export async function doPlexLogin({ win, headers, clientId }: Plex): Promise<PlexPin> {
const pin = await getPlexPin(headers);
win.location.href = `https://app.plex.tv/auth/#!?clientID=${clientId}&code=${pin.code}&context=Watcharr&context[device][device]=${headers["X-Plex-Device"]}&context[device][deviceName]=${headers["X-Plex-Device-Name"]}&context[device][platform]=${headers["X-Plex-Platform"]}&context[device][platformVersion]=${headers["X-Plex-Platform-Version"]}&context[device][product]=${headers["X-Plex-Product"]}`;
return pin;
}
export async function plexPinPoll(
pin: PlexPin,
{ win, headers }: Plex,
done: (err?: Error, token?: string) => void
) {
const doPoll = async () => {
try {
console.debug("plexPinPoll");
const r = await noAuthAxios.get(`https://plex.tv/api/v2/pins/${pin.id}`, {
headers: { ...headers, code: pin.code }
});
if (r.data?.authToken) {
done(undefined, r.data.authToken);
win.close();
} else if (win?.closed) {
done(new Error("Plex popup closed before login completed"));
} else {
setTimeout(doPoll, 1000);
}
} catch (err) {
console.error("plexPinPoll: Failed!", err);
done(new Error("Pin poll failed"));
}
};
setTimeout(doPoll, 1000);
}
async function getPlexPin(headers: Record<string, string>): Promise<PlexPin> {
const r = await noAuthAxios.post(`https://plex.tv/api/v2/pins?strong=true`, undefined, {
headers: headers
});
console.debug("getPlexPin:", r.data);
return { id: r.data.id, code: r.data.code };
}
+6 -2
View File
@@ -56,12 +56,16 @@
}
:global(input, textarea) {
padding: 5px 10px;
padding: 7px 10px;
border: 2px solid black;
border-radius: 5px;
width: 100%;
}
:global(input.invalid) {
border: 3px solid $error;
}
:global(textarea) {
padding: 5px;
max-width: 100%;
@@ -79,7 +83,7 @@
display: flex;
align-items: center;
justify-content: center;
padding: 5px 10px;
padding: 7px 10px;
border: 2px solid $text-color;
border-radius: 5px;
background-color: $bg-color;
+1 -2
View File
@@ -22,7 +22,6 @@
export let data;
let trailer: string | undefined;
let requestModalShown = false;
let trailerShown = false;
$: wList = $watchedList;
@@ -203,7 +202,7 @@
{/if}
{#if wListItem}
<Activity activity={wListItem?.activity} />
<Activity wListId={wListItem.id} activity={wListItem.activity} />
{/if}
</div>
</div>
+1 -1
View File
@@ -229,7 +229,7 @@
<SimilarContent type="movie" similar={movie.similar} />
{#if wListItem}
<Activity activity={wListItem?.activity} />
<Activity wListId={wListItem.id} activity={wListItem.activity} />
{/if}
</div>
</div>
+27 -6
View File
@@ -9,11 +9,12 @@
import { updateUserSetting } from "@/lib/util/api";
import { getOrdinalSuffix, monthsShort, toggleTheme } from "@/lib/util/helpers";
import { appTheme, userInfo, userSettings } from "@/store";
import type { Image, Profile } from "@/types";
import { UserType, type Image, type Profile } from "@/types";
import axios from "axios";
import { notify } from "@/lib/util/notify";
import UserAvatar from "@/lib/img/UserAvatar.svelte";
import PwChangeModal from "@/routes/(app)/profile/modals/PwChangeModal.svelte";
import SyncModal from "./modals/SyncModal.svelte";
$: user = $userInfo;
$: settings = $userSettings;
@@ -26,6 +27,8 @@
let includePreviouslyWatchedDisabled = false;
let pwChangeModalOpen = false;
let getProfilePromise = getProfile();
let jellyfinSyncModalOpen = false;
let plexSyncModalOpen = false;
async function getProfile() {
return (await axios.get(`/profile`)).data as Profile;
@@ -261,11 +264,23 @@
<div class="row btns">
<button on:click={() => goto("/import")}>Import</button>
<button on:click={() => downloadWatchedList()} disabled={exportDisabled}>Export</button>
<button
on:click={() => {
pwChangeModalOpen = true;
}}>Change Password</button
>
{#if user?.type !== UserType.Plex && user?.type !== UserType.Jellyfin}
<button
on:click={() => {
pwChangeModalOpen = true;
}}>Change Password</button
>
{/if}
{#if user?.type === UserType?.Jellyfin}
<button on:click={() => (jellyfinSyncModalOpen = true)} disabled={exportDisabled}>
Sync With Jellyfin
</button>
{/if}
{#if user?.type === UserType?.Plex}
<button on:click={() => (plexSyncModalOpen = true)} disabled={exportDisabled}>
Sync With Plex
</button>
{/if}
</div>
{#if pwChangeModalOpen}
<PwChangeModal
@@ -275,6 +290,12 @@
}}
></PwChangeModal>
{/if}
{#if jellyfinSyncModalOpen}
<SyncModal onClose={() => (jellyfinSyncModalOpen = false)} />
{/if}
{#if plexSyncModalOpen}
<SyncModal type="plex" onClose={() => (plexSyncModalOpen = false)} />
{/if}
</div>
</div>
</div>
@@ -0,0 +1,200 @@
<script lang="ts">
import Icon from "@/lib/Icon.svelte";
import Modal from "@/lib/Modal.svelte";
import Spinner from "@/lib/Spinner.svelte";
import { notify } from "@/lib/util/notify";
import { JobStatus, type GetJobResponse, type JobCreatedResponse } from "@/types";
import axios from "axios";
import { onDestroy, onMount } from "svelte";
import { watchedList } from "@/store";
export let type: "jellyfin" | "plex" = "jellyfin";
export let onClose: () => void;
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" = "starting";
let jobId: string | undefined;
let currentTask: string | undefined;
let latestJobStatus: GetJobResponse | undefined;
async function startSync() {
try {
const r = await axios.get<JobCreatedResponse>(
type === "jellyfin" ? "/jellyfin/sync" : "/plex/sync"
);
console.log("startSync: Response:", r.data);
if (!r.data.jobId) {
step = "errored";
console.error("startSync: No jobId returned!");
return;
}
jobId = r.data.jobId;
step = "job-running";
startJobWatcher();
} catch (err) {
console.error("startSync failed!", err);
step = "errored";
}
}
async function startJobWatcher() {
if (!jobId) {
console.error("startJobWatcher: No Job Id");
notify({ text: "Unable to start job watcher, no job id.", type: "error" });
return;
}
console.log("startJobWatcher: Starting..");
let seqfailedJobReqs = 0;
while (step === "job-running") {
try {
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
console.log("jobWatcher: Got job data:", r.data);
latestJobStatus = r.data;
currentTask = r.data?.currentTask;
if (r.data?.status === JobStatus.DONE) {
step = "done";
} else if (r.data?.status === JobStatus.CANCELLED) {
step = "errored";
}
// If we get here without erroring, we can reset it to 0.
seqfailedJobReqs = 0;
} catch (err) {
console.error("jobWatcher: Get job request failed!", seqfailedJobReqs, err);
seqfailedJobReqs++;
}
if (seqfailedJobReqs >= 10) {
console.error("jobWatcher: Failed 10 times in a row!");
notify({
text: "Status checker has failed 10 times in a row!",
type: "error",
time: 30000
});
step = "errored";
break;
}
await new Promise((r) => setTimeout(r, 1000));
}
if (step !== "modal-closing") {
// Update our watched list
const nid = notify({ text: "Fetching updated watched list.", type: "loading" });
try {
const w = await axios.get("/watched");
if (w?.data?.length > 0) {
watchedList.update((wl) => (wl = w.data));
notify({ id: nid, text: "Fetched updated watched list.", type: "success" });
}
} catch (err) {
console.error("jobWatcher: Getting updated watched list failed!", err);
notify({ id: nid, text: "Getting updated watched list failed!", type: "error" });
}
}
}
function modalClose() {
if (step === "job-running") {
notify({
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
time: 10000
});
}
step = "modal-closing";
onClose();
}
onMount(() => {
startSync();
});
onDestroy(() => {
step = "starting";
jobId = undefined;
currentTask = undefined;
latestJobStatus = undefined;
});
</script>
<Modal
title="{type === 'jellyfin' ? 'Jellyfin' : 'Plex'} Sync"
maxWidth="700px"
onClose={modalClose}
>
<div class="ctr">
{#if step === "done"}
<Icon i="check" wh={60} />
{:else if step === "errored"}
<Icon i="close" wh={70} />
{:else}
<Spinner />
{/if}
<div>
{#if step === "starting"}
<h4 class="norm">Starting</h4>
<span>We are requesting a full sync</span>
{:else if step === "job-running"}
<h4 class="norm">Syncing</h4>
{#if currentTask}
<span>{currentTask}</span>
{/if}
{:else if step === "done"}
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
<h4 class="norm">Finished</h4>
<span>We have finished syncing. Looks like there were no errors!</span>
{:else}
<h4 class="norm">
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus?.errors
?.length === 1
? ""
: "s"}
</h4>
<span>Syncing has finished, but with errors:</span>
<ul>
{#each latestJobStatus?.errors as e}
<li>{e}</li>
{/each}
</ul>
{/if}
{:else if step === "errored"}
<h4 class="norm">We Errored!</h4>
<span>We errored before starting sync or the sync job was cancelled.</span>
{:else}
<h4 class="norm">Unknown State!</h4>
<span>We're not sure of the current sync status.</span>
{/if}
</div>
</div>
</Modal>
<style lang="scss">
.ctr {
display: flex;
flex-flow: row;
gap: 20px;
justify-content: start;
align-items: start;
margin-top: 25px;
margin-bottom: 15px;
margin-left: 15px;
& > div:last-of-type {
display: flex;
flex-flow: column;
gap: 8px;
padding: 8px 0;
& > span {
font-style: italic;
&::first-letter {
text-transform: uppercase;
}
}
& > ul {
padding-left: 25px;
li::first-letter {
text-transform: uppercase;
}
}
}
}
</style>
+30 -3
View File
@@ -29,6 +29,7 @@
let debugDisabled = false;
let jfDisabled = false;
let tmdbkDisabled = false;
let plexHostDisabled = false;
async function getServerConfig() {
serverConfig = (await axios.get(`/server/config`)).data as ServerConfig;
@@ -37,19 +38,23 @@
export function updateServerConfig<K extends keyof ServerConfig>(
name: K,
value: ServerConfig[K],
done?: () => void
done?: (respData?: any) => void
) {
console.log("Updating server setting", name, "to", value);
const originalValue = serverConfig[name];
const nid = notify({ type: "loading", text: "Updating" });
let ep = "/server/config";
if (name === "PLEX_HOST") {
ep = "/server/config/plex_host";
}
axios
.post("/server/config", { key: name, value: value })
.post(ep, { key: name, value: value })
.then((r) => {
if (r.status === 200) {
serverConfig[name] = value;
serverConfig = serverConfig;
notify({ id: nid, type: "success", text: "Updated" });
if (typeof done !== "undefined") done();
if (typeof done !== "undefined") done(r?.data);
}
})
.catch((err) => {
@@ -133,6 +138,28 @@
disabled={jfDisabled}
/>
</Setting>
<Setting
title="Plex Host"
desc="Point to your Plex server to enable related features. Don't change server after
already using another."
>
<input
type="text"
placeholder="https://plex.example.com"
bind:value={serverConfig.PLEX_HOST}
on:blur={() => {
plexHostDisabled = true;
updateServerConfig("PLEX_HOST", serverConfig.PLEX_HOST, (rData) => {
plexHostDisabled = false;
serverConfig.PLEX_MACHINE_ID = rData?.PLEX_MACHINE_ID;
});
}}
disabled={plexHostDisabled}
/>
{#if serverConfig.PLEX_MACHINE_ID}
<span style="font-size: 10px">Machine Id: {serverConfig.PLEX_MACHINE_ID}</span>
{/if}
</Setting>
<Setting title="TMDB Key" desc="Provide your own TMDB API Key">
<input
type="password"
+1 -1
View File
@@ -227,7 +227,7 @@
<SimilarContent type="tv" similar={show.similar} />
{#if wListItem}
<Activity activity={wListItem?.activity} />
<Activity wListId={wListItem.id} activity={wListItem.activity} />
{/if}
<SeasonsList tvId={data.tvId} seasons={show.seasons} watchedItem={wListItem} />
</div>
+65 -8
View File
@@ -19,12 +19,12 @@
noAuthAxios.get<AvailableAuthProviders>("/auth/available").then((r) => {
if (r?.data) {
if (r?.data?.isInSetup) {
if (r.data.isInSetup) {
console.log("Server is in setup.. navigating to web setup page.");
goto("/setup");
}
availableProviders = r?.data?.available;
signupEnabled = r?.data?.signupEnabled;
availableProviders = r.data.available;
signupEnabled = r.data.signupEnabled;
}
});
});
@@ -73,6 +73,47 @@
unNotify(nid);
});
}
async function plexLogin() {
try {
const { preparePlexAuth, doPlexLogin, plexPinPoll } = await import("@/lib/util/plex");
const p = preparePlexAuth();
const pin = await doPlexLogin(p);
plexPinPoll(pin, p, (err, token) => {
if (err) {
error = "Plex Auth Failed";
console.error("Plex auth failed!", err);
return;
}
const nid = notify({ text: "Logging in", type: "loading" });
noAuthAxios
.post("/auth/plex", {
token,
clientIdentifier: p.clientId
})
.then((resp) => {
if (resp.data?.token) {
console.log("Received token... logging in.");
localStorage.setItem("token", resp.data.token);
goto("/");
notify({ id: nid, text: `Welcome!`, type: "success" });
}
})
.catch((err) => {
console.error("plexLogin: Fail", err);
if (err.response) {
error = err.response.data.error;
} else {
error = err.message;
}
notify({ id: nid, text: `Failed!`, type: "error" });
});
});
} catch (err) {
console.error("plexLogin: failed!", err);
error = "Plex login failed";
}
}
</script>
<div>
@@ -100,12 +141,27 @@
<span class="login-with" style="font-weight: bold">Login With</span>
<div class="login-btns">
<button type="submit"><span class="watcharr">W</span>Watcharr</button>
{#if availableProviders}
{#each availableProviders as p}
<button type="submit" name="jellyfin" class="other"><Icon i={p} wh={18} />{p}</button>
{#if availableProviders?.length > 0}
{#each availableProviders.filter((ap) => ap !== "plex") as p}
<button type="submit" name={p} class="other"><Icon i={p} wh={18} />{p}</button>
{/each}
{/if}
</div>
{#if availableProviders?.findIndex((provider) => provider == "plex") > -1}
<p style="font-weight: bold; font-size: 14px;">or</p>
<div class="login-btns">
<button
type="button"
on:click={() => {
plexLogin();
}}
name="plex"
class="plex other"
>
<Icon i="plex" wh={18} />Continue with Plex
</button>
</div>
{/if}
{:else}
<div class="login-btns">
<button type="submit">Sign Up</button>
@@ -169,11 +225,12 @@
display: flex;
flex-flow: row;
gap: 10px;
text-transform: capitalize;
.watcharr {
font-family: "Rampart One";
font-size: 18px;
line-height: 18px;
font-size: 19px;
line-height: 19px;
}
&.other {
+30 -2
View File
@@ -11,6 +11,7 @@ export type Icon =
| "play"
| "pause"
| "jellyfin"
| "plex"
| "trash"
| "close"
| "filter"
@@ -40,10 +41,12 @@ export type ExtraDetailsGame = { dateAdded: string; dateModified: string };
export enum UserType {
// Assume watcharr user if none of these...
Jellyfin = 1
Jellyfin = 1,
Plex = 2
}
interface dbModel {
id: number;
createdAt: string;
updatedAt: string;
deletedAt: string;
@@ -85,7 +88,6 @@ export interface WatchedEpisode {
}
export interface Watched extends dbModel {
id: number;
watched: boolean;
rating?: number;
content?: Content;
@@ -121,6 +123,10 @@ export interface WatchedUpdateResponse {
newActivity: Activity;
}
export interface ActivityUpdateRequest {
customDate: string;
}
export interface WatchedSeasonAddResponse {
watchedSeasons: WatchedSeason[];
addedActivity: Activity;
@@ -190,6 +196,7 @@ export interface AvailableAuthProviders {
available: string[];
signupEnabled: boolean;
isInSetup: boolean;
plexOauthId: string;
}
export interface TokenClaims {
@@ -752,6 +759,8 @@ export interface ServerConfig {
JELLYFIN_HOST: string;
SIGNUP_ENABLED: boolean;
TMDB_KEY: string;
PLEX_HOST: string;
PLEX_MACHINE_ID: string;
SONARR: SonarrSettings[];
RADARR: RadarrSettings[];
TWITCH: TwitchSettings;
@@ -975,3 +984,22 @@ export enum GameWebsiteCategory {
Steam = 13,
Reddit = 14
}
// General interface for all requests that return a job that was started.
export interface JobCreatedResponse {
jobId: string;
}
export enum JobStatus {
CREATED = "CREATED",
RUNNING = "RUNNING",
DONE = "DONE",
CANCELLED = "CANCELLED"
}
export interface GetJobResponse {
name: string;
status: JobStatus;
currentTask?: string;
errors: string[];
}
+1
View File
@@ -38,6 +38,7 @@ $poster-rating-color: var(--poster-rating-color);
$poster-extra-detail-bg-color: rgba(46, 46, 46, 0.5);
$error: #f3555a;
$success: #28a745;
$plex: #e5a00d;
// For ratings that are on bg-color.
$rating-color: var(--rating-color);