mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 15:25:29 +00:00
Compare commits
205 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b21d2bf603 | |||
| 0667f33250 | |||
| 0da264480c | |||
| 622d10e743 | |||
| 7c216f4691 | |||
| b5f9700345 | |||
| 7e03557e56 | |||
| 10a79a31cd | |||
| e41d408cd0 | |||
| 6b22b4a4e2 | |||
| 67c3083edd | |||
| e968b7ff28 | |||
| adfd983a68 | |||
| b32ba86c5d | |||
| ebb0b132bd | |||
| 4f1fbf4a45 | |||
| 74f896a8bc | |||
| 3b1d42c148 | |||
| 7a902212a5 | |||
| 2a718ef6d8 | |||
| f71dc287bd | |||
| 77d2c6f49a | |||
| 3eb454cc93 | |||
| 8a5fd8182f | |||
| aa7a38b2ee | |||
| ed7a780c44 | |||
| 32f72f382e | |||
| 2e0ea4d6c7 | |||
| 2a9a3691a3 | |||
| 49692f5e02 | |||
| 5f06e7b52f | |||
| 1933ca350d | |||
| 337957fb9f | |||
| 458c91d505 | |||
| 5498236b48 | |||
| 0e7cdb0199 | |||
| 7d25b0bf5d | |||
| ccadcf2152 | |||
| 8e0fb190d1 | |||
| 62033c66aa | |||
| 95130fd4fb | |||
| c4cebb717c | |||
| d92dd51bc8 | |||
| 4e4f8f9fcf | |||
| 11a0ec55d5 | |||
| 14b3f38b5d | |||
| 5799cd649b | |||
| d4b873ec7d | |||
| d00ae04074 | |||
| 8955ca7b26 | |||
| 82c6729a22 | |||
| 1929272705 | |||
| 709dead520 | |||
| 7cd348804c | |||
| ba91f65cee | |||
| a09488dbba | |||
| 111b2b1afd | |||
| c0ff3205a6 | |||
| 92815f6907 | |||
| d9257e767f | |||
| 26ec0f592a | |||
| 1e4814d50a | |||
| bbe2123c1e | |||
| d7c2897698 | |||
| 8cd930369d | |||
| 4cea72d8e9 | |||
| e9a4629c2a | |||
| 3e02af9c40 | |||
| 2a5a470347 | |||
| 07e3f5f7a8 | |||
| 1b83c70b5e | |||
| 1e5cdc151b | |||
| dcb21a6e1e | |||
| 0b4f3d5c97 | |||
| 0d8ea359e4 | |||
| 1c44b1812a | |||
| 3d5cf70ec0 | |||
| f1f26d0f57 | |||
| e3fe047afd | |||
| 4afc32f9d1 | |||
| 7ea78f2d57 | |||
| 6092410b20 | |||
| 8f5293a769 | |||
| 6d25356d30 | |||
| dbd86b89c7 | |||
| bf0b4fbb6a | |||
| 5585053b85 | |||
| a61b82e9af | |||
| d4fe6d3653 | |||
| 6d2ab6e7e4 | |||
| c5e6ae702a | |||
| 2a9b0443f5 | |||
| e1e933af16 | |||
| a5fbecd32a | |||
| 98006efb03 | |||
| 926b0f8267 | |||
| cc3f21aa93 | |||
| e311bcd901 | |||
| eb66baba80 | |||
| 09795b4da3 | |||
| 8088184e6b | |||
| 1b6c56dd8f | |||
| 86195eb682 | |||
| f40ffa124c | |||
| 704b6380c7 | |||
| b434665caf | |||
| 3554d4bc60 | |||
| 917ddd5401 | |||
| e7cf21fdb2 | |||
| c932bd188a | |||
| e5d17bee81 | |||
| 65836a1654 | |||
| 0f7bdaffbb | |||
| 30dae4c5c9 | |||
| f67d1641db | |||
| 0ff02e00c9 | |||
| 194081ac2f | |||
| 896f80ebd6 | |||
| 862279e914 | |||
| bdf5cf54af | |||
| 4323ab7086 | |||
| 555b237ed0 | |||
| 5146af2672 | |||
| 4d3da51ae7 | |||
| 9cba877fe6 | |||
| 924ffcb33f | |||
| b638062d36 | |||
| da64b5ca59 | |||
| 8f2d9b0984 | |||
| c2e85ff8b3 | |||
| 3c9d074169 | |||
| 7a254222a1 | |||
| 4662977677 | |||
| 798c80f69b | |||
| d8e4182824 | |||
| ac9fb8c28d | |||
| d0b1b9ed17 | |||
| 548643327d | |||
| a86d2a218a | |||
| 21ecc4cbbf | |||
| 2e19cad3a8 | |||
| 8bffc7380e | |||
| 96720caace | |||
| 6c130952d7 | |||
| 3c1fc0e950 | |||
| d95990fc56 | |||
| 9d0a433121 | |||
| 2bd02439c2 | |||
| 6a1fdd69e3 | |||
| fb7dc5d0f8 | |||
| c3d480d47b | |||
| 274c97bcdd | |||
| d6b5fc9cd3 | |||
| 4de9865a39 | |||
| d278f2cf98 | |||
| 5aec83c2f0 | |||
| fbda3d4b9b | |||
| dde8fad248 | |||
| e8309568bc | |||
| 997e3afb7e | |||
| 891fc51bcd | |||
| ebe42f1d34 | |||
| b799c660ca | |||
| a10f570983 | |||
| f205f96010 | |||
| 735a98e0b2 | |||
| f87aa70a3b | |||
| a2dc418ee6 | |||
| 4c7982f7be | |||
| 43d603c212 | |||
| ebf623ed1b | |||
| d925519beb | |||
| 07b72452e8 | |||
| ab4781bec0 | |||
| 7719b6336c | |||
| e749e94bfb | |||
| c1ceb17ada | |||
| 91dd00b556 | |||
| 60e873b4c0 | |||
| 4333591c9c | |||
| 462a46aabb | |||
| c766d68d49 | |||
| 6b7c38aa7a | |||
| 367c79f29c | |||
| c59eb82a1a | |||
| 3d40e5554f | |||
| 75f091cfa0 | |||
| 1d0abe4982 | |||
| f8490481a5 | |||
| 1adc56f965 | |||
| 8861233aaa | |||
| 3c4718bc45 | |||
| f6fa9e7666 | |||
| 98a7c370a8 | |||
| 37f9bffb13 | |||
| a4573fe52a | |||
| aeaaccda88 | |||
| 3a2ce0c718 | |||
| 2ac80d0b8c | |||
| 3ff5d9836b | |||
| e6d486c2a2 | |||
| c4e591e2f2 | |||
| 0eb1a88103 | |||
| c767572905 | |||
| b3ba00cd0d |
@@ -1,3 +1,6 @@
|
||||
# Multi arch support doc/guide:
|
||||
# https://docs.docker.com/build/ci/github-actions/multi-platform/
|
||||
|
||||
name: Development Watcharr Image Release
|
||||
|
||||
on:
|
||||
@@ -18,6 +21,12 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -30,6 +39,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
|
||||
@@ -20,6 +20,12 @@ jobs:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
@@ -38,6 +44,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -40,3 +40,8 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
data/
|
||||
|
||||
# Build artifacts
|
||||
server/ui
|
||||
server/watcharr
|
||||
|
||||
@@ -4,6 +4,7 @@ FROM golang:1.21-alpine AS server
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
COPY server/arr/*.go ./arr/
|
||||
COPY server/game/*.go ./game/
|
||||
|
||||
# Required so we can build with cgo
|
||||
RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
|
||||
@@ -7,9 +7,12 @@
|
||||
<a href="https://watcharr.app"><img src="https://img.shields.io/website?label=DOCS&style=for-the-badge&url=https%3A%2F%2Fwatcharr.app" /></a>
|
||||
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
|
||||
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
|
||||
<a href="https://matrix.to/#/#watcharr:matrix.org"><img src="https://img.shields.io/matrix/watcharr%3Amatrix.org?style=for-the-badge&logo=matrix" /></a>
|
||||
</p>
|
||||
|
||||
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) movies and tv shows, rate them and track their status.
|
||||
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) **movies** and **tv shows** (and **anime**), rate them and track their status.
|
||||
|
||||
With [some extra configuration](https://watcharr.app/docs/server_config/game-support-igdb) I can also track your **video games**.
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
@@ -53,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.
|
||||
|
||||
@@ -22,6 +22,7 @@ services:
|
||||
volumes:
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
:::danger first account
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Games Support (via IGDB/Twitch)
|
||||
|
||||
## Overview
|
||||
|
||||
Game support in Watcharr, currently, must be enabled by you.
|
||||
|
||||
This involves setting up a Twitch developer account (phone number needed for 2FA which is required) and entering application credentials into Watcharr, that can then be used for looking up game info via IGDB.
|
||||
|
||||
P.S. These steps are stolen from [igdb api docs](https://api-docs.igdb.com/#account-creation), I've tried to make it easier to follow with screenshots.
|
||||
|
||||
## Steps
|
||||
|
||||
### Setup On Twitch
|
||||
|
||||
1. Sign up for a [Twitch](https://dev.twitch.tv/login) account.
|
||||
Click login with twitch, then create an account in the Sign Up tab.
|
||||
2. Enable two factor authentication in your [account Security settings](https://www.twitch.tv/settings/security) (currently requires a phone number).
|
||||
3. [Register a new Application in the Twitch Developer Console](https://dev.twitch.tv/console/apps/create).
|
||||
These settings will do (name can be anything, we don't use OAuth, but a redirect url is required; localhost will do):
|
||||
|
||||

|
||||
|
||||
4. [Click manage](https://dev.twitch.tv/console/apps) on your newly created application.
|
||||
5. Click 'New Secret' to generate a new Client Secret.
|
||||
6. Take a note of your Client ID and new Client Secret (by leaving this tab open, until we are ready to copy the values into Watcharr).
|
||||
|
||||
### Setup On Watcharr
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down and click on the Twitch service.
|
||||
|
||||

|
||||
|
||||
3. Enter your Client ID and Client Secret (from Twitch Application page, step 6 on twitch setup steps)
|
||||
|
||||

|
||||
|
||||
4. Click save, and start searching for your favourite games!
|
||||
|
||||

|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 675 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.2 KiB |
@@ -1,8 +1,9 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||
const { themes } = require("prism-react-renderer");
|
||||
const lightTheme = themes.github;
|
||||
const darkTheme = themes.dracula;
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
@@ -53,7 +54,10 @@ const config = {
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve("./src/css/custom.css")
|
||||
customCss: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css")
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -79,6 +83,11 @@ const config = {
|
||||
{
|
||||
type: "docsVersionDropdown"
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
@@ -87,8 +96,8 @@ const config = {
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme
|
||||
// darkTheme: darkCodeTheme
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
|
||||
Generated
+4822
-2949
File diff suppressed because it is too large
Load Diff
+10
-9
@@ -14,16 +14,17 @@
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.4.3",
|
||||
"@docusaurus/preset-classic": "2.4.3",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
"@docusaurus/core": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.3"
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
@@ -38,6 +39,6 @@
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
"node": ">=18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+190
-175
@@ -1,36 +1,36 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.29.0",
|
||||
"version": "1.35.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "1.29.0",
|
||||
"version": "1.35.2",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"axios": "^1.6.7",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^2.0.2",
|
||||
"@sveltejs/kit": "^2.0.6",
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||
"@typescript-eslint/parser": "^6.16.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.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.8",
|
||||
"svelte-check": "^3.6.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte-check": "^3.6.4",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
"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": {
|
||||
@@ -2749,9 +2749,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.1.tgz",
|
||||
"integrity": "sha512-6vMdBZqtq1dVQ4CWdhFwhKZL6E4L1dV6jUjuBvsavvNJSppzi6dLBbuV+3+IyUREaj9ZFvQefnQm28v4OCXlig==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.9.6.tgz",
|
||||
"integrity": "sha512-MVNXSSYN6QXOulbHpLMKYi60ppyO13W9my1qogeiAqtjb2yR4LSmfU2+POvDkLzhjYLXz9Rf9+9a3zFHW1Lecg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2762,9 +2762,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.1.tgz",
|
||||
"integrity": "sha512-Jto9Fl3YQ9OLsTDWtLFPtaIMSL2kwGyGoVCmPC8Gxvym9TCZm4Sie+cVeblPO66YZsYH8MhBKDMGZ2NDxuk/XQ==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.9.6.tgz",
|
||||
"integrity": "sha512-T14aNLpqJ5wzKNf5jEDpv5zgyIqcpn1MlwCrUXLrwoADr2RkWA0vOWP4XxbO9aiO3dvMCQICZdKeDrFl7UMClw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2775,9 +2775,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.1.tgz",
|
||||
"integrity": "sha512-LtYcLNM+bhsaKAIGwVkh5IOWhaZhjTfNOkGzGqdHvhiCUVuJDalvDxEdSnhFzAn+g23wgsycmZk1vbnaibZwwA==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.9.6.tgz",
|
||||
"integrity": "sha512-CqNNAyhRkTbo8VVZ5R85X73H3R5NX9ONnKbXuHisGWC0qRbTTxnF1U4V9NafzJbgGM0sHZpdO83pLPzq8uOZFw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2788,9 +2788,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.1.tgz",
|
||||
"integrity": "sha512-KyP/byeXu9V+etKO6Lw3E4tW4QdcnzDG/ake031mg42lob5tN+5qfr+lkcT/SGZaH2PdW4Z1NX9GHEkZ8xV7og==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.9.6.tgz",
|
||||
"integrity": "sha512-zRDtdJuRvA1dc9Mp6BWYqAsU5oeLixdfUvkTHuiYOHwqYuQ4YgSmi6+/lPvSsqc/I0Omw3DdICx4Tfacdzmhog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2801,9 +2801,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.1.tgz",
|
||||
"integrity": "sha512-Yqz/Doumf3QTKplwGNrCHe/B2p9xqDghBZSlAY0/hU6ikuDVQuOUIpDP/YcmoT+447tsZTmirmjgG3znvSCR0Q==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.9.6.tgz",
|
||||
"integrity": "sha512-oNk8YXDDnNyG4qlNb6is1ojTOGL/tRhbbKeE/YuccItzerEZT68Z9gHrY3ROh7axDc974+zYAPxK5SH0j/G+QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2814,9 +2814,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.1.tgz",
|
||||
"integrity": "sha512-u3XkZVvxcvlAOlQJ3UsD1rFvLWqu4Ef/Ggl40WAVCuogf4S1nJPHh5RTgqYFpCOvuGJ7H5yGHabjFKEZGExk5Q==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.9.6.tgz",
|
||||
"integrity": "sha512-Z3O60yxPtuCYobrtzjo0wlmvDdx2qZfeAWTyfOjEDqd08kthDKexLpV97KfAeUXPosENKd8uyJMRDfFMxcYkDQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2827,9 +2827,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.1.tgz",
|
||||
"integrity": "sha512-0XSYN/rfWShW+i+qjZ0phc6vZ7UWI8XWNz4E/l+6edFt+FxoEghrJHjX1EY/kcUGCnZzYYRCl31SNdfOi450Aw==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.9.6.tgz",
|
||||
"integrity": "sha512-gpiG0qQJNdYEVad+1iAsGAbgAnZ8j07FapmnIAQgODKcOTjLEWM9sRb+MbQyVsYCnA0Im6M6QIq6ax7liws6eQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2840,9 +2840,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.1.tgz",
|
||||
"integrity": "sha512-LmYIO65oZVfFt9t6cpYkbC4d5lKHLYv5B4CSHRpnANq0VZUQXGcCPXHzbCXCz4RQnx7jvlYB1ISVNCE/omz5cw==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.9.6.tgz",
|
||||
"integrity": "sha512-+uCOcvVmFUYvVDr27aiyun9WgZk0tXe7ThuzoUTAukZJOwS5MrGbmSlNOhx1j80GdpqbOty05XqSl5w4dQvcOA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2853,9 +2853,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.1.tgz",
|
||||
"integrity": "sha512-kr8rEPQ6ns/Lmr/hiw8sEVj9aa07gh1/tQF2Y5HrNCCEPiCBGnBUt9tVusrcBBiJfIt1yNaXN6r1CCmpbFEDpg==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.9.6.tgz",
|
||||
"integrity": "sha512-HUNqM32dGzfBKuaDUBqFB7tP6VMN74eLZ33Q9Y1TBqRDn+qDonkAUyKWwF9BR9unV7QUzffLnz9GrnKvMqC/fw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2866,9 +2866,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.1.tgz",
|
||||
"integrity": "sha512-t4QSR7gN+OEZLG0MiCgPqMWZGwmeHhsM4AkegJ0Kiy6TnJ9vZ8dEIwHw1LcZKhbHxTY32hp9eVCMdR3/I8MGRw==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.9.6.tgz",
|
||||
"integrity": "sha512-ch7M+9Tr5R4FK40FHQk8VnML0Szi2KRujUgHXd/HjuH9ifH72GUmw6lStZBo3c3GB82vHa0ZoUfjfcM7JiiMrQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2879,9 +2879,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.1.tgz",
|
||||
"integrity": "sha512-7XI4ZCBN34cb+BH557FJPmh0kmNz2c25SCQeT9OiFWEgf8+dL6ZwJ8f9RnUIit+j01u07Yvrsuu1rZGxJCc51g==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.9.6.tgz",
|
||||
"integrity": "sha512-VD6qnR99dhmTQ1mJhIzXsRcTBvTjbfbGGwKAHcu+52cVl15AC/kplkhxzW/uT0Xl62Y/meBKDZvoJSJN+vTeGA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2892,9 +2892,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.1.tgz",
|
||||
"integrity": "sha512-yE5c2j1lSWOH5jp+Q0qNL3Mdhr8WuqCNVjc6BxbVfS5cAS6zRmdiw7ktb8GNpDCEUJphILY6KACoFoRtKoqNQg==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.9.6.tgz",
|
||||
"integrity": "sha512-J9AFDq/xiRI58eR2NIDfyVmTYGyIZmRcvcAoJ48oDld/NTR8wyiPUu2X/v1navJ+N/FGg68LEbX3Ejd6l8B7MQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2905,9 +2905,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.1.tgz",
|
||||
"integrity": "sha512-PyJsSsafjmIhVgaI1Zdj7m8BB8mMckFah/xbpplObyHfiXzKcI5UOUXRyOdHW7nz4DpMCuzLnF7v5IWHenCwYA==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.9.6.tgz",
|
||||
"integrity": "sha512-jqzNLhNDvIZOrt69Ce4UjGRpXJBzhUBzawMwnaDAwyHriki3XollsewxWzOzz+4yOFDkuJHtTsZFwMxhYJWmLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2941,24 +2941,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/adapter-node": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-2.0.2.tgz",
|
||||
"integrity": "sha512-iboANjLIB7Af74+og5IEQVSPsIfaO+o9zldU/7ljCGCrBj0t2gQlINtuwUhjvwhLgw9vHgICYWWZFxLM2C0zrg==",
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-4.0.1.tgz",
|
||||
"integrity": "sha512-IviiTtKCDp+0QoTmmMlGGZBA1EoUNsjecU6XGV9k62S3f01SNsVhpqi2e4nbI62BLGKh/YKKfFii+Vz/b9XIxg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@rollup/plugin-commonjs": "^25.0.7",
|
||||
"@rollup/plugin-json": "^6.1.0",
|
||||
"@rollup/plugin-node-resolve": "^15.2.3",
|
||||
"rollup": "^4.8.0"
|
||||
"rollup": "^4.9.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": "^2.0.0"
|
||||
"@sveltejs/kit": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.0.6.tgz",
|
||||
"integrity": "sha512-dnHtyjBLGXx+hrZQ9GuqLlSfTBixewJaByUVWai7LmB4dgV3FwkK155OltEgONDQW6KW64hLNS/uojdx3uC2/g==",
|
||||
"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": {
|
||||
@@ -2966,6 +2966,7 @@
|
||||
"cookie": "^0.6.0",
|
||||
"devalue": "^4.3.2",
|
||||
"esm-env": "^1.0.0",
|
||||
"import-meta-resolve": "^4.0.0",
|
||||
"kleur": "^4.1.5",
|
||||
"magic-string": "^0.30.5",
|
||||
"mrmime": "^2.0.0",
|
||||
@@ -3034,9 +3035,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz",
|
||||
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
|
||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
@@ -3076,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": {
|
||||
@@ -3089,16 +3090,16 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.16.0.tgz",
|
||||
"integrity": "sha512-O5f7Kv5o4dLWQtPX4ywPPa+v9G+1q1x8mz0Kr0pXUtKsevo+gIJHLkGc8RxaZWtP8RrhwhSNIWThnW42K9/0rQ==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/type-utils": "6.16.0",
|
||||
"@typescript-eslint/utils": "6.16.0",
|
||||
"@typescript-eslint/visitor-keys": "6.16.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",
|
||||
@@ -3114,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,15 +3125,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.16.0.tgz",
|
||||
"integrity": "sha512-H2GM3eUo12HpKZU9njig3DF5zJ58ja6ahj1GoHEHOgQvYxzoFJJEvC1MQ7T2l9Ha+69ZSOn7RTxOdpC/y3ikMw==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/types": "6.16.0",
|
||||
"@typescript-eslint/typescript-estree": "6.16.0",
|
||||
"@typescript-eslint/visitor-keys": "6.16.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": {
|
||||
@@ -3143,7 +3144,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
"eslint": "^8.56.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -3152,13 +3153,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-6.16.0.tgz",
|
||||
"integrity": "sha512-0N7Y9DSPdaBQ3sqSCwlrm9zJwkpOuc6HYm7LpzLAPqBL7dmzAUimr4M29dMkOP/tEwvOCC/Cxo//yOfJD3HUiw==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/visitor-keys": "6.16.0"
|
||||
"@typescript-eslint/types": "7.2.0",
|
||||
"@typescript-eslint/visitor-keys": "7.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^16.0.0 || >=18.0.0"
|
||||
@@ -3169,13 +3170,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-6.16.0.tgz",
|
||||
"integrity": "sha512-ThmrEOcARmOnoyQfYkHw/DX2SEYBalVECmoldVuH6qagKROp/jMnfXpAU/pAIWub9c4YTxga+XwgAkoA0pxfmg==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/utils": "6.16.0",
|
||||
"@typescript-eslint/typescript-estree": "7.2.0",
|
||||
"@typescript-eslint/utils": "7.2.0",
|
||||
"debug": "^4.3.4",
|
||||
"ts-api-utils": "^1.0.1"
|
||||
},
|
||||
@@ -3187,7 +3188,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
"eslint": "^8.56.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
@@ -3196,9 +3197,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-6.16.0.tgz",
|
||||
"integrity": "sha512-hvDFpLEvTJoHutVl87+MG/c5C8I6LOgEx05zExTSJDEVU7hhR3jhV8M5zuggbdFCw98+HhZWPHZeKS97kS3JoQ==",
|
||||
"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"
|
||||
@@ -3209,13 +3210,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-6.16.0.tgz",
|
||||
"integrity": "sha512-VTWZuixh/vr7nih6CfrdpmFNLEnoVBF1skfjdyGnNwXOH1SLeHItGdZDHhhAIzd3ACazyY2Fg76zuzOVTaknGA==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/visitor-keys": "6.16.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",
|
||||
@@ -3261,17 +3262,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-6.16.0.tgz",
|
||||
"integrity": "sha512-T83QPKrBm6n//q9mv7oiSvy/Xq/7Hyw9SzSEhMHJwznEmQayfBM87+oAlkNAMEO7/MjIwKyOHgBJbxB0s7gx2A==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/types": "6.16.0",
|
||||
"@typescript-eslint/typescript-estree": "6.16.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": {
|
||||
@@ -3282,16 +3283,16 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^7.0.0 || ^8.0.0"
|
||||
"eslint": "^8.56.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "6.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-6.16.0.tgz",
|
||||
"integrity": "sha512-QSFQLruk7fhs91a/Ep/LqRdbJCZ1Rq03rqBdKT5Ky17Sz8zRLUksqIe9DW0pKtg/Z35/ztbLQ6qpOCN6rOC11A==",
|
||||
"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.16.0",
|
||||
"@typescript-eslint/types": "7.2.0",
|
||||
"eslint-visitor-keys": "^3.4.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3497,19 +3498,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.2.tgz",
|
||||
"integrity": "sha512-7i24Ri4pmDRfJTR7LDBhsOTtcm+9kjX5WiY1X3wIisx6G9So3pfMkEiU7emUBe46oceVImccTEM3k6C5dbVW8A==",
|
||||
"version": "1.6.7",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.6.7.tgz",
|
||||
"integrity": "sha512-/hDJGff6/c7u0hDkvkGxR/oy6CbCs8ziCsC7SqmhjfozqiJGc8Z11wrv9z9lYfY4K8l+H9TpjcMDX0xOZmx+RA==",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.15.0",
|
||||
"follow-redirects": "^1.15.4",
|
||||
"form-data": "^4.0.0",
|
||||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz",
|
||||
"integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==",
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.0.0.tgz",
|
||||
"integrity": "sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"dequal": "^2.0.3"
|
||||
@@ -4227,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",
|
||||
@@ -4587,9 +4588,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.2",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
|
||||
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
|
||||
"version": "1.15.4",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.4.tgz",
|
||||
"integrity": "sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -5011,6 +5012,16 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/import-meta-resolve": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz",
|
||||
"integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==",
|
||||
"dev": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/imurmurhash": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
|
||||
@@ -5992,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": [
|
||||
{
|
||||
@@ -6113,9 +6124,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz",
|
||||
"integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==",
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
@@ -6128,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",
|
||||
@@ -6364,10 +6375,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.1.tgz",
|
||||
"integrity": "sha512-pgPO9DWzLoW/vIhlSoDByCzcpX92bKEorbgXuZrqxByte3JFk2xSW2JEeAcyLc9Ru9pqcNNW+Ob7ntsk2oT/Xw==",
|
||||
"version": "4.9.6",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.9.6.tgz",
|
||||
"integrity": "sha512-05lzkCS2uASX0CiLFybYfVkwNbKZG5NFQ6Go0VWyogFTXXbR039UVsegViTntkk4OglHBdF54ccApXRRuXRbsg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.5"
|
||||
},
|
||||
"bin": {
|
||||
"rollup": "dist/bin/rollup"
|
||||
},
|
||||
@@ -6376,19 +6390,19 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.9.1",
|
||||
"@rollup/rollup-android-arm64": "4.9.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.9.1",
|
||||
"@rollup/rollup-darwin-x64": "4.9.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.9.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.9.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.9.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.9.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.9.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.9.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.9.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.9.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.9.6",
|
||||
"@rollup/rollup-android-arm64": "4.9.6",
|
||||
"@rollup/rollup-darwin-arm64": "4.9.6",
|
||||
"@rollup/rollup-darwin-x64": "4.9.6",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.9.6",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.9.6",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.9.6",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.9.6",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.9.6",
|
||||
"@rollup/rollup-linux-x64-musl": "4.9.6",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.9.6",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.9.6",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.9.6",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -6867,17 +6881,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "4.2.8",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.8.tgz",
|
||||
"integrity": "sha512-hU6dh1MPl8gh6klQZwK/n73GiAHiR95IkFsesLPbMeEZi36ydaXL/ZAb4g9sayT0MXzpxyZjR28yderJHxcmYA==",
|
||||
"version": "4.2.10",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.10.tgz",
|
||||
"integrity": "sha512-Ep06yCaCdgG1Mafb/Rx8sJ1QS3RW2I2BxGp2Ui9LBHSZ2/tO/aGLc5WqPjgiAP6KAnLJGaIr/zzwQlOo1b8MxA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.1",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@jridgewell/trace-mapping": "^0.3.18",
|
||||
"@types/estree": "^1.0.1",
|
||||
"acorn": "^8.9.0",
|
||||
"aria-query": "^5.3.0",
|
||||
"axobject-query": "^3.2.1",
|
||||
"axobject-query": "^4.0.0",
|
||||
"code-red": "^1.0.3",
|
||||
"css-tree": "^2.3.1",
|
||||
"estree-walker": "^3.0.3",
|
||||
@@ -6891,9 +6906,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/svelte-check": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.2.tgz",
|
||||
"integrity": "sha512-E6iFh4aUCGJLRz6QZXH3gcN/VFfkzwtruWSRmlKrLWQTiO6VzLsivR6q02WYLGNAGecV3EocqZuCDrC2uttZ0g==",
|
||||
"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",
|
||||
@@ -7422,13 +7437,13 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.10.tgz",
|
||||
"integrity": "sha512-2P8J7WWgmc355HUMlFrwofacvr98DAjoE52BfdbwQtyLH06XKwaL/FMnmKM2crF0iX4MpmMKoDlNCB1ok7zHCw==",
|
||||
"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": {
|
||||
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.29.0",
|
||||
"version": "1.35.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -13,28 +13,28 @@
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^2.0.2",
|
||||
"@sveltejs/kit": "^2.0.6",
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||
"@typescript-eslint/parser": "^6.16.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.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^4.2.8",
|
||||
"svelte-check": "^3.6.2",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.1",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte-check": "^3.6.4",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.0.10"
|
||||
"vite": "^5.1.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"axios": "^1.6.7",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
|
||||
+65
-12
@@ -11,18 +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
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
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 {
|
||||
@@ -48,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)
|
||||
@@ -71,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
|
||||
}
|
||||
|
||||
+159
-10
@@ -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
|
||||
}
|
||||
@@ -80,6 +84,34 @@ type UserSettings struct {
|
||||
PrivateThoughts *bool `gorm:"default:false" json:"privateThoughts"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
// If user wants previously watched items to show in 'Finished' filter,
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -125,6 +157,16 @@ type ArgonParams struct {
|
||||
keyLength uint32
|
||||
}
|
||||
|
||||
func GetPassArgonParams() *ArgonParams {
|
||||
return &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
@@ -132,6 +174,11 @@ type TokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type UserPasswordUpdateRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
@@ -214,13 +261,7 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
}
|
||||
var user User = User{Username: ur.Username, Password: ur.Password}
|
||||
slog.Info("A user is registering", "username", user.Username)
|
||||
hash, err := hashPassword(user.Password, &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
})
|
||||
hash, err := hashPassword(user.Password, GetPassArgonParams())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -356,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
|
||||
@@ -390,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)
|
||||
@@ -539,3 +653,38 @@ func hasPermission(perms int, reqPerm int) bool {
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
|
||||
func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
|
||||
slog.Debug("userChangePassword request running", "user_id", userId)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Select("password").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("userChangePassword failed - failed to retrieve user from database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to retrieve user")
|
||||
}
|
||||
slog.Debug("userChangePassword user found", "user_id", userId)
|
||||
match, err := compareHash(pwds.OldPassword, user.Password)
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to compare passwords", "user_id", userId, "error", err)
|
||||
return errors.New("failed to compare passwords")
|
||||
}
|
||||
if !match {
|
||||
slog.Error("userChangePassword failed - current password hash doesn't match password hash in database", "user_id", userId, "error", err)
|
||||
return errors.New("current password provided doesn't match password in database")
|
||||
}
|
||||
slog.Debug("userChangePassword hash for current password matches hash in the database", "user_id", userId)
|
||||
slog.Debug("userChangePassword hashing new password", "user_id", userId)
|
||||
hash, err := hashPassword(pwds.NewPassword, GetPassArgonParams())
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to hash new password", "user_id", userId, "error", err)
|
||||
return errors.New("failed to hash new password")
|
||||
}
|
||||
slog.Debug("userChangePassword new password hashed", "user_id", userId)
|
||||
if err := db.Model(&User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
|
||||
slog.Error("userChangePassword failed - failed to update password in database", "user_id", userId, "error", err)
|
||||
return errors.New("failed to update password")
|
||||
} else {
|
||||
slog.Debug("userChangePassword password updated", "user_id", userId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+66
-18
@@ -6,7 +6,9 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -23,14 +25,24 @@ 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"`
|
||||
|
||||
// Enable/disable debug logging. Useful for when trying
|
||||
// to figure out exactly what the server is doing at a point
|
||||
@@ -47,21 +59,24 @@ 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,
|
||||
}, // Dont act safe, this contains twitch secrets, needed for config
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. set defaults here, then `readConfig`
|
||||
// will overwrite if provided in watcharr.json cfg file.
|
||||
Config = ServerConfig{
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
)
|
||||
|
||||
// Read config file
|
||||
@@ -95,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)
|
||||
}
|
||||
|
||||
@@ -144,6 +162,7 @@ func writeConfig() error {
|
||||
type ServerFeatures struct {
|
||||
Sonarr bool `json:"sonarr"`
|
||||
Radarr bool `json:"radarr"`
|
||||
Games bool `json:"games"`
|
||||
}
|
||||
|
||||
// Get enabled server functionality from Config.
|
||||
@@ -151,6 +170,9 @@ type ServerFeatures struct {
|
||||
// which btns should be shown, etc.
|
||||
func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
var f ServerFeatures
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
// https://github.com/sbondCo/Watcharr/issues/211
|
||||
// Currently requesting permissions have not been setup, only admins for now.
|
||||
if !hasPermission(userPerms, PERM_ADMIN) {
|
||||
@@ -165,6 +187,32 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
return f
|
||||
}
|
||||
|
||||
func saveTwitchConfig(c game.IGDB) error {
|
||||
// If existing client id and secret are same.. just return here
|
||||
if (Config.TWITCH.ClientID != nil && c.ClientID != nil && Config.TWITCH.ClientSecret != nil && c.ClientSecret != nil) &&
|
||||
*Config.TWITCH.ClientID == *c.ClientID && *Config.TWITCH.ClientSecret == *c.ClientSecret {
|
||||
slog.Info("saveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
|
||||
return nil
|
||||
}
|
||||
// Update our config
|
||||
Config.TWITCH.ClientID = c.ClientID
|
||||
Config.TWITCH.ClientSecret = c.ClientSecret
|
||||
Config.TWITCH.AccessToken = ""
|
||||
Config.TWITCH.AccessTokenExpires = time.Time{}
|
||||
// Try to init again
|
||||
err := Config.TWITCH.Init()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to initialize TWITCH", "error", err)
|
||||
return errors.New("initialization with credentials failed")
|
||||
}
|
||||
err = writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to write config", "error", err)
|
||||
return errors.New("failed to save config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ServerStats struct {
|
||||
Users int64 `json:"users"`
|
||||
PrivateUsers int64 `json:"privateUsers"`
|
||||
@@ -206,13 +254,13 @@ func getServerStats(db *gorm.DB) ServerStats {
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = w.Content
|
||||
stats.MostWatchedShow = *w.Content
|
||||
}
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedMovie = w.Content
|
||||
stats.MostWatchedMovie = *w.Content
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
+143
-5
@@ -3,7 +3,11 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
@@ -34,6 +38,138 @@ type Content struct {
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
|
||||
func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
|
||||
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
|
||||
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
|
||||
return errors.New("content missing id or title")
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached content in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title",
|
||||
"poster_path",
|
||||
"overview",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"vote_average",
|
||||
"vote_count",
|
||||
"imdb_id",
|
||||
"status",
|
||||
"budget",
|
||||
"revenue",
|
||||
"runtime",
|
||||
"number_of_episodes",
|
||||
"number_of_seasons",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join("./data/img", c.PosterPath))
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheContentTv(db *gorm.DB, content TMDBShowDetails, onlyUpdate bool) (Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Name,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: SHOW,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
Status: content.Status,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: content.NumberOfEpisodes,
|
||||
NumberOfSeasons: content.NumberOfSeasons,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Title,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: MOVIE,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
ImdbID: content.ImdbID,
|
||||
Status: content.Status,
|
||||
Budget: content.Budget,
|
||||
Revenue: content.Revenue,
|
||||
Runtime: content.Runtime,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
@@ -68,7 +204,7 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
@@ -76,6 +212,7 @@ func movieDetails(id string, country string, rParams map[string]string) (TMDBMov
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -89,7 +226,7 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
@@ -97,6 +234,7 @@ func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDe
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -104,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
|
||||
@@ -142,7 +280,7 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
err := tmdbRequest("/discover/movie/", map[string]string{"page": "1"}, &resp)
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
@@ -152,7 +290,7 @@ func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
resp := new(TMDBDiscoverShows)
|
||||
err := tmdbRequest("/discover/tv/", map[string]string{"page": "1"}, &resp)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
|
||||
+28
-8
@@ -88,7 +88,7 @@ func getFollows(db *gorm.DB, userId uint) ([]FollowPublic, error) {
|
||||
}
|
||||
|
||||
// Get followed profile thoughts, rating, etc on specific content.
|
||||
func getFollowsThoughts(db *gorm.DB, userId uint, mediaType ContentType, tmdbId string) ([]FollowThoughts, error) {
|
||||
func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId string) ([]FollowThoughts, error) {
|
||||
var follows []Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ? AND private_thoughts = ?", 0, 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
@@ -104,16 +104,36 @@ func getFollowsThoughts(db *gorm.DB, userId uint, mediaType ContentType, tmdbId
|
||||
}
|
||||
followIds = append(followIds, v.FollowedUser.ID)
|
||||
}
|
||||
// Get our content id from type and tmdbId
|
||||
var content Content
|
||||
res = db.Where("type = ? AND tmdb_id = ?", mediaType, tmdbId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
var contentOrGameId int
|
||||
if mediaType == "game" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Game
|
||||
res = db.Where("igdb_id = ?", mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
}
|
||||
contentOrGameId = content.ID
|
||||
} else if mediaType == "movie" || mediaType == "tv" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Content
|
||||
res = db.Where("type = ? AND tmdb_id = ?", mediaType, mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find content")
|
||||
}
|
||||
contentOrGameId = content.ID
|
||||
} else {
|
||||
slog.Error("getFollows: Unrecognized media type (movie, tv or game supported).", "media_type", mediaType)
|
||||
return []FollowThoughts{}, errors.New("unrecognized media type")
|
||||
}
|
||||
// Get list of followeds watcheds for this content
|
||||
var fw []Watched
|
||||
res = db.Where("content_id = ? AND user_id IN ?", content.ID, followIds).Find(&fw)
|
||||
if mediaType == "game" {
|
||||
res = db.Where("game_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
} else {
|
||||
res = db.Where("content_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
}
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding followed watcheds from db.", "error", res.Error)
|
||||
return []FollowThoughts{}, errors.New("failed to find followed watcheds")
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
igdbHost = "https://api.igdb.com/v4"
|
||||
tokenGrantType = "client_credentials"
|
||||
)
|
||||
|
||||
var tokenRefreshJobCancel context.CancelFunc
|
||||
|
||||
type IGDB struct {
|
||||
ClientID *string `json:"clientId,omitempty"`
|
||||
ClientSecret *string `json:"clientSecret,omitempty"`
|
||||
AccessToken string `json:"accessToken,omitempty"`
|
||||
AccessTokenExpires time.Time `json:"accessTokenExpires,omitempty"`
|
||||
onTokenRefreshed *func()
|
||||
}
|
||||
|
||||
func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp interface{}) error {
|
||||
// if using igdb host and we have no access token, error before running req
|
||||
if host == igdbHost && (i.ClientID == nil || i.AccessToken == "") {
|
||||
return errors.New("using igdbHost without a clientID or accessToken")
|
||||
}
|
||||
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
slog.Info("req", "base", base.String())
|
||||
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer([]byte(b)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Add igdb auth headers
|
||||
if host == igdbHost {
|
||||
req.Header.Add("Client-ID", *i.ClientID)
|
||||
req.Header.Add("Authorization", "Bearer "+i.AccessToken)
|
||||
}
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
||||
slog.Error("game non 2xx status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) getNewAccessToken() (TwitchTokenResponse, error) {
|
||||
var resp TwitchTokenResponse
|
||||
err := i.req(
|
||||
"https://id.twitch.tv/oauth2",
|
||||
"/token",
|
||||
map[string]string{"client_id": *i.ClientID, "client_secret": *i.ClientSecret, "grant_type": tokenGrantType},
|
||||
"",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB init token request failed", "error", err)
|
||||
return TwitchTokenResponse{}, errors.New("token request failed, check client id and secret")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) refreshToken(ctx context.Context) {
|
||||
var exp <-chan time.Time
|
||||
|
||||
if i.AccessToken != "" && !i.AccessTokenExpires.IsZero() && i.AccessTokenExpires.Compare(time.Now()) == 1 {
|
||||
// Stored token not expired.. set exp time to token exp time - 1h.
|
||||
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
||||
} else {
|
||||
// Token expired.. exp now..
|
||||
exp = time.After(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
slog.Info("refreshToken running")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-exp:
|
||||
slog.Info("IGDB refreshToken: Token expired (or is near expiry date)")
|
||||
r, err := i.getNewAccessToken()
|
||||
if err != nil {
|
||||
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):", err)
|
||||
exp = time.After(60 * time.Second)
|
||||
} else {
|
||||
slog.Info("IGDB refreshToken: Token successfully refreshed")
|
||||
i.AccessToken = r.AccessToken
|
||||
i.AccessTokenExpires = time.Now().Add(time.Duration(r.ExpiresIn) * time.Second)
|
||||
exp = time.After(i.AccessTokenExpires.Sub(time.Now().Add(60 * time.Second)))
|
||||
// Call token refresh callback if set
|
||||
if i.onTokenRefreshed != nil {
|
||||
(*i.onTokenRefreshed)()
|
||||
}
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
slog.Info("refreshToken cancelled")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (i *IGDB) OnTokenRefreshed(tokenRefreshed func()) {
|
||||
i.onTokenRefreshed = &tokenRefreshed
|
||||
}
|
||||
|
||||
// Get token and stuff
|
||||
func (i *IGDB) Init() error {
|
||||
// Cancel existing refresh job if we have a cancel func for its context
|
||||
if tokenRefreshJobCancel != nil {
|
||||
slog.Debug("IGDB init: Refresh job running.. cancelling it.")
|
||||
tokenRefreshJobCancel()
|
||||
tokenRefreshJobCancel = nil
|
||||
}
|
||||
// Stop here if we have no client id or secret.
|
||||
if i.ClientID == nil || i.ClientSecret == nil {
|
||||
slog.Error("IGDB init client id and or secret not provided")
|
||||
return errors.New("client id and or secret not provided")
|
||||
}
|
||||
slog.Debug("IGDB init running.")
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
tokenRefreshJobCancel = cancel
|
||||
// Get and set first token if needed
|
||||
go i.refreshToken(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
slog.Debug("IGDB Search called", "query", q)
|
||||
var resp GameSearchResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields name, cover.image_id, version_title, summary, first_release_date; search \""+q+"\";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB Search request failed!", "error", err)
|
||||
return GameSearchResponse{}, errors.New("request failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
`fields
|
||||
name,
|
||||
cover.image_id,
|
||||
version_title,
|
||||
summary,
|
||||
storyline,
|
||||
first_release_date,
|
||||
artworks.width,
|
||||
artworks.height,
|
||||
artworks.image_id,
|
||||
category,
|
||||
platforms.name,
|
||||
game_modes.name,
|
||||
genres.id,
|
||||
genres.name,
|
||||
involved_companies.developer,
|
||||
involved_companies.publisher,
|
||||
involved_companies.porting,
|
||||
involved_companies.supporting,
|
||||
involved_companies.company.name,
|
||||
involved_companies.company.description,
|
||||
involved_companies.company.slug,
|
||||
involved_companies.company.websites.category,
|
||||
involved_companies.company.websites.trusted,
|
||||
involved_companies.company.websites.url,
|
||||
rating,
|
||||
rating_count,
|
||||
status,
|
||||
url,
|
||||
websites.trusted,
|
||||
websites.category,
|
||||
websites.url,
|
||||
videos.name,
|
||||
videos.video_id,
|
||||
similar_games.id,
|
||||
similar_games.name,
|
||||
similar_games.summary,
|
||||
similar_games.cover.image_id,
|
||||
similar_games.first_release_date;
|
||||
where id = `+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB GameDetails request failed!", "error", err)
|
||||
return GameDetailsResponse{}, errors.New("request failed")
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
return resp[0], nil
|
||||
}
|
||||
return GameDetailsResponse{}, errors.New("no game details recieved")
|
||||
}
|
||||
|
||||
// Basic game details for when we are using them only to update our cache.
|
||||
// In these cases, it's a waste to ask for everything, when we don't need it.
|
||||
func (i *IGDB) GameDetailsBasic(id string) (GameDetailsBasicResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsBasicResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
`fields
|
||||
name,
|
||||
cover.image_id,
|
||||
summary,
|
||||
storyline,
|
||||
first_release_date,
|
||||
category,
|
||||
platforms.name,
|
||||
game_modes.name,
|
||||
genres.name,
|
||||
rating,
|
||||
rating_count,
|
||||
status;
|
||||
where id = `+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB GameDetails request failed!", "error", err)
|
||||
return GameDetailsBasicResponse{}, errors.New("request failed")
|
||||
}
|
||||
if len(resp) > 0 {
|
||||
return resp[0], nil
|
||||
}
|
||||
return GameDetailsBasicResponse{}, errors.New("no game details recieved")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TwitchTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
|
||||
// So we can unmarshall the unix timestamps returned from igdb into time.Time.
|
||||
type UnixTime struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func (u *UnixTime) UnmarshalJSON(b []byte) error {
|
||||
var timestamp int64
|
||||
err := json.Unmarshal(b, ×tamp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Time = time.Unix(timestamp, 0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only the fields we request included in each struct
|
||||
|
||||
type GameSearchResponse []struct {
|
||||
ID int `json:"id"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
VersionTitle string `json:"version_title,omitempty"`
|
||||
}
|
||||
|
||||
type GameDetailsResponse struct {
|
||||
ID int `json:"id"`
|
||||
Artworks []struct {
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"artworks"`
|
||||
Category int `json:"category"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
GameModes []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"game_modes"`
|
||||
Genres []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
InvolvedCompanies []struct {
|
||||
ID int `json:"id"`
|
||||
Company struct {
|
||||
ID int `json:"id"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Websites []struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Trusted bool `json:"trusted"`
|
||||
URL string `json:"url"`
|
||||
} `json:"websites"`
|
||||
} `json:"company"`
|
||||
Developer bool `json:"developer"`
|
||||
Porting bool `json:"porting"`
|
||||
Publisher bool `json:"publisher"`
|
||||
Supporting bool `json:"supporting"`
|
||||
} `json:"involved_companies"`
|
||||
Name string `json:"name"`
|
||||
Platforms []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
Status int `json:"status"`
|
||||
URL string `json:"url"`
|
||||
Videos []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
VideoID string `json:"video_id"`
|
||||
} `json:"videos"`
|
||||
Websites []struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Trusted bool `json:"trusted"`
|
||||
URL string `json:"url"`
|
||||
} `json:"websites"`
|
||||
SimilarGame []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Summary string `json:"summary"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
Cover struct {
|
||||
ID int `json:"id"`
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
} `json:"similar_games"`
|
||||
}
|
||||
|
||||
type GameDetailsBasicResponse struct {
|
||||
ID int `json:"id"`
|
||||
Category int `json:"category"`
|
||||
Cover struct {
|
||||
ImageID string `json:"image_id"`
|
||||
} `json:"cover"`
|
||||
FirstReleaseDate UnixTime `json:"first_release_date"`
|
||||
GameModes []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"game_modes"`
|
||||
Genres []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"genres"`
|
||||
Name string `json:"name"`
|
||||
Platforms []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"platforms"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"rating_count"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
+212
@@ -0,0 +1,212 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// For storing cached games, so we can serve the basic local data for watched list to work
|
||||
type Game struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
IgdbID int `json:"igdbId" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name"`
|
||||
CoverID string `json:"coverId"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
// First release date
|
||||
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"ratingCount"`
|
||||
Status int `json:"status"`
|
||||
Category int `json:"category"`
|
||||
// Arrays turned to strings that may be useful
|
||||
GameModes string `json:"gameModes"`
|
||||
Genres string `json:"genres"`
|
||||
Platforms string `json:"platforms"`
|
||||
// Id to poster image row (cached game cover)
|
||||
PosterID *uint `json:"-"`
|
||||
Poster *Image `json:"poster,omitempty"`
|
||||
}
|
||||
|
||||
type PlayedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
}
|
||||
|
||||
// Cache(save) game to our table
|
||||
func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
|
||||
slog.Info("Saving game to db", "id", c.IgdbID, "name", c.Name)
|
||||
if c.IgdbID == 0 || c.Name == "" {
|
||||
slog.Error("saveGame: content missing id or name!", "id", c.IgdbID, "name", c.Name)
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := downloadAndInsertImage(db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
slog.Debug("saveGame: Cached game cover", "p", p)
|
||||
c.PosterID = &p.ID
|
||||
}
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Game{}).Where("igdb_id = ?", c.IgdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveGame: Error updating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached game in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "igdb_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"cover_id",
|
||||
"summary",
|
||||
"storyline",
|
||||
"release_date",
|
||||
"rating",
|
||||
"rating_count",
|
||||
"status",
|
||||
"game_modes",
|
||||
"genres",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveGame: Error creating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache game in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (Game, error) {
|
||||
slog.Debug("cacheGame", "game_details", g)
|
||||
var (
|
||||
gameModes string
|
||||
genres string
|
||||
platforms string
|
||||
)
|
||||
if len(g.GameModes) > 0 {
|
||||
for _, v := range g.GameModes {
|
||||
gameModes += v.Name + "|"
|
||||
}
|
||||
}
|
||||
if len(g.Genres) > 0 {
|
||||
for _, v := range g.Genres {
|
||||
genres += v.Name + "|"
|
||||
}
|
||||
}
|
||||
if len(g.Platforms) > 0 {
|
||||
for _, v := range g.Platforms {
|
||||
platforms += v.Name + "|"
|
||||
}
|
||||
}
|
||||
c := Game{
|
||||
IgdbID: g.ID,
|
||||
Name: g.Name,
|
||||
CoverID: g.Cover.ImageID,
|
||||
Summary: g.Summary,
|
||||
Storyline: g.Storyline,
|
||||
ReleaseDate: &g.FirstReleaseDate.Time,
|
||||
Rating: (g.Rating),
|
||||
RatingCount: g.RatingCount,
|
||||
Status: g.Status,
|
||||
Category: g.Category,
|
||||
GameModes: gameModes,
|
||||
Genres: genres,
|
||||
Platforms: platforms,
|
||||
}
|
||||
err := saveGame(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheGame: Failed to save game!", "error", err)
|
||||
return Game{}, errors.New("failed to save game")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// For adding/updating played games, we will reuse methods defined in watched.go where easily possible.
|
||||
|
||||
func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding played item", "userId", userId, "igdbId", ar.IgdbID)
|
||||
|
||||
var game Game
|
||||
db.Where("igdb_id = ?", ar.IgdbID).Find(&game)
|
||||
|
||||
// Create game if not found from our db
|
||||
if game == (Game{}) {
|
||||
slog.Debug("Game not in db, fetching...")
|
||||
|
||||
resp, err := igdb.GameDetailsBasic(strconv.Itoa(ar.IgdbID))
|
||||
if err != nil {
|
||||
slog.Error("addPlayed content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested games")
|
||||
}
|
||||
|
||||
game, err = cacheGame(db, resp, false)
|
||||
if err != nil {
|
||||
slog.Error("addPlayed failed to cache game", "igdb_id", ar.IgdbID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
// Error if content has no id
|
||||
if game.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find game by id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = FINISHED
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, GameID: &game.ID}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addPlayed: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
slog.Error("addPlayed: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var activity Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Game = &game
|
||||
return watched, nil
|
||||
}
|
||||
+17
-17
@@ -5,48 +5,48 @@ 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.17.0
|
||||
golang.org/x/crypto v0.21.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
gorm.io/driver/sqlite v1.5.5
|
||||
gorm.io/gorm v1.25.7
|
||||
)
|
||||
|
||||
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.15.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
|
||||
)
|
||||
|
||||
+34
-38
@@ -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.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
|
||||
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
|
||||
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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
|
||||
golang.org/x/sys v0.15.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=
|
||||
@@ -122,9 +118,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
+87
-2
@@ -1,15 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
@@ -29,7 +36,7 @@ type Image struct {
|
||||
}
|
||||
|
||||
// Insert an image into database
|
||||
func insertImage(db *gorm.DB, hash string, path string, f multipart.File) (Image, error) {
|
||||
func insertImage(db *gorm.DB, hash string, path string, f io.Reader) (Image, error) {
|
||||
bh, _ := getBlurHash(f)
|
||||
img := Image{
|
||||
Hash: hash,
|
||||
@@ -44,7 +51,7 @@ func insertImage(db *gorm.DB, hash string, path string, f multipart.File) (Image
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func getBlurHash(img multipart.File) (string, error) {
|
||||
func getBlurHash(img io.Reader) (string, error) {
|
||||
i, _, err := image.Decode(img)
|
||||
if err != nil {
|
||||
// Handle errors
|
||||
@@ -114,3 +121,81 @@ func isValidImageType(f multipart.File) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
// So we have the ability to seek.
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
log.Fatal(err) // TODO nu le fatal
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Seek back for file
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
} else {
|
||||
return Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
img, err := insertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
+38
-8
@@ -27,11 +27,14 @@ var (
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -49,14 +52,14 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
if ar.TmdbID != 0 && (ar.Type == MOVIE || ar.Type == SHOW) {
|
||||
tid := strconv.Itoa(ar.TmdbID)
|
||||
if ar.Type == MOVIE {
|
||||
cr, err := movieDetails(tid, "", map[string]string{})
|
||||
cr, err := movieDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("movie details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of movie", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, MOVIE, ar)
|
||||
} else if ar.Type == SHOW {
|
||||
cr, err := tvDetails(tid, "", map[string]string{})
|
||||
cr, err := tvDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("tv details request failed")
|
||||
}
|
||||
@@ -115,11 +118,26 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
}
|
||||
|
||||
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType, ar ImportRequest) (ImportResponse, error) {
|
||||
status := FINISHED
|
||||
if ar.Status != "" {
|
||||
status = ar.Status
|
||||
}
|
||||
// Get the latest date from DatesWatched if we have any.
|
||||
var wDate time.Time
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, dw := range ar.DatesWatched {
|
||||
if dw.After(wDate) {
|
||||
wDate = dw
|
||||
}
|
||||
}
|
||||
}
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
Status: status,
|
||||
ContentID: contentId,
|
||||
ContentType: contentType,
|
||||
Rating: ar.Rating,
|
||||
Thoughts: ar.Thoughts,
|
||||
WatchedDate: wDate,
|
||||
}, IMPORTED_WATCHED)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
@@ -140,5 +158,17 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
}
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
}
|
||||
// Add all dates watched as activity, if any
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, v := range ar.DatesWatched {
|
||||
customDate := v
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
|
||||
+45
-12
@@ -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")
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+87
-9
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -9,9 +10,56 @@ import (
|
||||
)
|
||||
|
||||
type Profile struct {
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
Joined time.Time `json:"joined"`
|
||||
ShowsWatched int32 `json:"showsWatched"`
|
||||
MoviesWatched int32 `json:"moviesWatched"`
|
||||
MoviesWatchedRuntime uint32 `json:"moviesWatchedRuntime"`
|
||||
ShowsWatchedRuntime uint32 `json:"showsWatchedRuntime"`
|
||||
}
|
||||
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []Activity
|
||||
for _, v := range *a {
|
||||
if v.Type == ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_WATCHED ||
|
||||
v.Type == STATUS_CHANGED {
|
||||
relatedActivity = append(relatedActivity, v)
|
||||
}
|
||||
}
|
||||
if len(relatedActivity) <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, ra := range relatedActivity {
|
||||
if ra.Type == IMPORTED_ADDED_WATCHED {
|
||||
wp = true
|
||||
break
|
||||
} else if ra.Type == ADDED_WATCHED || ra.Type == IMPORTED_WATCHED {
|
||||
if ra.Data == "" {
|
||||
continue
|
||||
}
|
||||
var v map[string]any
|
||||
err := json.Unmarshal([]byte(ra.Data), &v)
|
||||
if err != nil {
|
||||
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
|
||||
continue
|
||||
}
|
||||
if status, ok := v["status"]; ok {
|
||||
if status == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ra.Type == STATUS_CHANGED {
|
||||
if ra.Data == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp
|
||||
}
|
||||
|
||||
// Gets any data required for profile page
|
||||
@@ -23,24 +71,54 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
showsWatched int32
|
||||
moviesWatched int32
|
||||
showsWatched int32
|
||||
moviesWatched int32
|
||||
moviesWatchedRuntime uint32
|
||||
showsWatchedRuntime uint32
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
if w.Status == FINISHED {
|
||||
if w.Content.Type == SHOW {
|
||||
isFinished = true
|
||||
} else if *user.IncludePreviouslyWatched && hasBeenPreviouslyWatched(&w.Activity) {
|
||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
||||
// then we can also check if content hasBeenPreviouslyWatched.
|
||||
isFinished = true
|
||||
}
|
||||
if isFinished {
|
||||
if w.Content == nil {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
if c.Type == SHOW {
|
||||
showsWatched++
|
||||
} else if w.Content.Type == MOVIE {
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
var showRuntime uint32 = 30
|
||||
if c.Runtime != 0 {
|
||||
showRuntime = c.Runtime
|
||||
}
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
} else if c.Type == MOVIE {
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
profile := Profile{Joined: user.CreatedAt, ShowsWatched: showsWatched, MoviesWatched: moviesWatched}
|
||||
profile := Profile{
|
||||
Joined: user.CreatedAt,
|
||||
ShowsWatched: showsWatched,
|
||||
MoviesWatched: moviesWatched,
|
||||
MoviesWatchedRuntime: moviesWatchedRuntime,
|
||||
ShowsWatchedRuntime: showsWatchedRuntime,
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
+286
-29
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -17,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"`
|
||||
@@ -94,7 +99,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
content, err := movieDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -122,7 +127,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
content, err := tvDetails(b.db, c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar,external_ids,keywords"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -237,6 +242,90 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addGameRoutes() {
|
||||
gamer := b.rg.Group("/game").Use(AuthRequired(nil))
|
||||
exp := time.Hour * 24
|
||||
|
||||
igdb := &Config.TWITCH
|
||||
igdb.OnTokenRefreshed(func() {
|
||||
// Save new token to config when we refresh it.
|
||||
slog.Debug("GameRoutes: token refreshed.. saving to config.")
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("GameRoutes: failed to save refreshed token to config.", "error", err)
|
||||
}
|
||||
})
|
||||
err := igdb.Init()
|
||||
// Save cfg if init succeeded, this will save our access token
|
||||
if err == nil {
|
||||
slog.Error("GameRoutes: Twitch init failed!", "error", err)
|
||||
}
|
||||
|
||||
// Search for games
|
||||
gamer.GET("/search/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(c.Param("query"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}))
|
||||
|
||||
// Game details for game page
|
||||
gamer.GET("/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := igdb.GameDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Add game to played(watched) list
|
||||
gamer.POST("/played", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar PlayedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addPlayed(b.db, igdb, userId, ar, ADDED_WATCHED)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// IMPORTANT: Routes below only for admins!
|
||||
gamer.Use(AuthRequired(b.db), AdminRequired())
|
||||
{
|
||||
gamer.POST("/config", func(c *gin.Context) {
|
||||
var ar game.IGDB
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
err := saveTwitchConfig(ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
igdb = &Config.TWITCH
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
|
||||
|
||||
@@ -321,7 +410,7 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
var ar WatchedSeasonAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatchedSeason(b.db, userId, ar, ADDED_WATCHED)
|
||||
response, err := addWatchedSeason(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -346,6 +435,37 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST("/episode", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedEpisodeAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatchedEpisodes(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
watched.DELETE("/episode/:id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := rmWatchedEpisode(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
@@ -381,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() {
|
||||
@@ -416,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
|
||||
@@ -437,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,
|
||||
@@ -444,33 +619,54 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
})
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.Use(AuthRequired(nil)).GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
// Use admin token
|
||||
auth.Use(AuthRequired(nil)).POST("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := useAdminToken(&atr, b.db, userId)
|
||||
// IMPORTANT: Routes below here must be authenticated.
|
||||
auth.Use(AuthRequired(nil))
|
||||
{
|
||||
// Request admin token
|
||||
auth.GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
})
|
||||
|
||||
// Use admin token
|
||||
auth.POST("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := useAdminToken(&atr, b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Change password
|
||||
auth.POST("/change_password", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var pwds UserPasswordUpdateRequest
|
||||
err := c.ShouldBindJSON(&pwds)
|
||||
if err == nil {
|
||||
err := userChangePassword(b.db, pwds, userId)
|
||||
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()})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addProfileRoutes() {
|
||||
@@ -489,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) {
|
||||
@@ -505,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() {
|
||||
@@ -654,9 +881,9 @@ func (b *BaseRouter) addFollowRoutes() {
|
||||
|
||||
// Get follows thoughts on content
|
||||
f.GET("/thoughts/:type/:tmdbId", func(c *gin.Context) {
|
||||
t := ContentType(c.Param("type"))
|
||||
if t != MOVIE && t != SHOW {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "only movie or show types are supported"})
|
||||
t := c.Param("type")
|
||||
if t != "movie" && t != "tv" && t != "game" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "only movie, tv or game types are supported"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
@@ -714,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))
|
||||
@@ -935,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)
|
||||
})
|
||||
}
|
||||
|
||||
+15
-2
@@ -55,8 +55,16 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
if ur.PrivateThoughts != nil {
|
||||
user.PrivateThoughts = ur.PrivateThoughts
|
||||
}
|
||||
if ur.IncludePreviouslyWatched != nil {
|
||||
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{Private: user.Private, PrivateThoughts: user.PrivateThoughts, HideSpoilers: user.HideSpoilers}, nil
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
@@ -67,7 +75,12 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
slog.Error("user get failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
return UserSettings{Private: user.Private, PrivateThoughts: user.PrivateThoughts, HideSpoilers: user.HideSpoilers}, nil
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
|
||||
|
||||
+16
-1
@@ -69,7 +69,19 @@ func main() {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &WatchedSeason{}, &Activity{}, &Token{}, &Follow{}, &Image{})
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&UserServices{},
|
||||
&Content{},
|
||||
&Watched{},
|
||||
&WatchedSeason{},
|
||||
&WatchedEpisode{},
|
||||
&Activity{},
|
||||
&Token{},
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
@@ -115,10 +127,12 @@ func main() {
|
||||
}
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addGameRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addPlexRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addFollowRoutes()
|
||||
br.addImportRoutes()
|
||||
@@ -126,6 +140,7 @@ func main() {
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.addJobRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
|
||||
+42
-106
@@ -27,21 +27,28 @@ const (
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
ContentID int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content Content `json:"content"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
GameID *int `json:"-" gorm:"uniqueIndex:userngamidx"`
|
||||
Game *Game `json:"game,omitempty"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
// Pass a watched date and we will set the CreatedAt (and initial UpdatedAt)
|
||||
// properties for this watched entry to this specific date.
|
||||
WatchedDate time.Time `json:"watchedDate,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
@@ -61,7 +68,7 @@ type WatchedRemoveResponse struct {
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Activity").Preload("WatchedSeasons").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Preload("WatchedSeasons").Preload("WatchedEpisodes").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -87,7 +94,7 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -110,110 +117,29 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
var (
|
||||
id int
|
||||
title string
|
||||
overview string
|
||||
posterPath string
|
||||
releaseDate time.Time
|
||||
popularity float32
|
||||
voteAverage float32
|
||||
voteCount uint32
|
||||
imdbID string
|
||||
status string
|
||||
budget uint32
|
||||
revenue uint32
|
||||
runtime uint32
|
||||
numberOfEpisodes uint32
|
||||
numberOfSeasons uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
if ar.ContentType == "movie" {
|
||||
content := new(TMDBMovieDetails)
|
||||
err = json.Unmarshal([]byte(resp), &content)
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Title
|
||||
releaseDate, err = time.Parse(dateFormat, content.ReleaseDate)
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
slog.Error("addWatched failed to cache movie content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
voteCount = content.VoteCount
|
||||
imdbID = content.ImdbID
|
||||
status = content.Status
|
||||
budget = content.Budget
|
||||
revenue = content.Revenue
|
||||
runtime = content.Runtime
|
||||
} else {
|
||||
content := new(TMDBShowDetails)
|
||||
err = json.Unmarshal(resp, &content)
|
||||
c := new(TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal show details", "error", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return Watched{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
id = content.ID
|
||||
overview = content.Overview
|
||||
posterPath = content.PosterPath
|
||||
title = content.Name
|
||||
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
voteCount = content.VoteCount
|
||||
status = content.Status
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
numberOfEpisodes = content.NumberOfEpisodes
|
||||
numberOfSeasons = content.NumberOfSeasons
|
||||
}
|
||||
// Save the content in our db
|
||||
slog.Info("Saving content to db", "id", id, "title", title)
|
||||
if id == 0 || title == "" {
|
||||
slog.Error("addWatched, returned content missing id or title!", "id", id, "title", title)
|
||||
return Watched{}, errors.New("content response missing id or title")
|
||||
}
|
||||
content = Content{
|
||||
TmdbID: id,
|
||||
Title: title,
|
||||
Overview: overview,
|
||||
PosterPath: posterPath,
|
||||
Type: ar.ContentType,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: popularity,
|
||||
VoteAverage: voteAverage,
|
||||
VoteCount: voteCount,
|
||||
ImdbID: imdbID,
|
||||
Status: status,
|
||||
Budget: budget,
|
||||
Revenue: revenue,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: numberOfEpisodes,
|
||||
NumberOfSeasons: numberOfSeasons,
|
||||
}
|
||||
res := db.Create(&content)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("Error creating content in database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
|
||||
if err != nil {
|
||||
slog.Error("Failed to download content image!", "error", err.Error())
|
||||
slog.Error("addWatched failed to cache tv content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,7 +157,16 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
ar.Status = WATCHING
|
||||
}
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: content.ID}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: &content.ID}
|
||||
if ar.Thoughts != "" {
|
||||
watched.Thoughts = ar.Thoughts
|
||||
}
|
||||
// If custom WatchedDate passed, set CreatedAt and UpdatedAt fields to it.
|
||||
if !ar.WatchedDate.IsZero() {
|
||||
slog.Debug("Adding watched item: The provided WatchedDate is valid.", "watched_date", ar.WatchedDate, "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
watched.CreatedAt = ar.WatchedDate
|
||||
watched.UpdatedAt = ar.WatchedDate
|
||||
}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
@@ -240,12 +175,13 @@ 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})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
watched.Thoughts = ar.Thoughts
|
||||
if res.Error != nil {
|
||||
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
@@ -267,7 +203,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType)
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Content = content
|
||||
watched.Content = &content
|
||||
return watched, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// UniqueIndex applied between WatchedID, SeasonNum and EpisodeNum to avoid duplicates incase logic fails.
|
||||
//
|
||||
// Episodes on tmdb are only queried by season number + episode number, not possible via episode id,
|
||||
// since episodes can be removed and re-added. For this reason we store season and episodes nums instead
|
||||
// of just the episode id.
|
||||
type WatchedEpisode struct {
|
||||
GormModel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
EpisodeNumber int `json:"episodeNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
type WatchedEpisodeAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedEpisodeAddResponse struct {
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
AddedActivity Activity `json:"addedActivity"`
|
||||
}
|
||||
|
||||
// Add/edit a watched episode.
|
||||
func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (WatchedEpisodeAddResponse, error) {
|
||||
slog.Debug("Adding watched episode item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber, "episode", ar.EpisodeNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedEpisodes").Find(&w); resp.Error != nil {
|
||||
slog.Error("Failed when adding a watched episode", "error", "failed to get watched item from db")
|
||||
return WatchedEpisodeAddResponse{}, errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("Failed when adding a watched episode", "error", "watched item does not exist in db")
|
||||
return WatchedEpisodeAddResponse{}, errors.New("can't add a watched episode for a show that doesnt have a status itself")
|
||||
}
|
||||
if w.Content.Type != SHOW {
|
||||
return WatchedEpisodeAddResponse{}, errors.New("can't add watched episode for non show content")
|
||||
}
|
||||
found := false
|
||||
updated := false
|
||||
for i, we := range w.WatchedEpisodes {
|
||||
if we.SeasonNumber == ar.SeasonNumber && we.EpisodeNumber == ar.EpisodeNumber {
|
||||
slog.Debug("Existing watched episode item found, updating existing")
|
||||
found = true
|
||||
if ar.Status != "" && ar.Status != w.WatchedEpisodes[i].Status {
|
||||
w.WatchedEpisodes[i].Status = ar.Status
|
||||
updated = true
|
||||
}
|
||||
if ar.Rating != 0 && ar.Rating != w.WatchedEpisodes[i].Rating {
|
||||
w.WatchedEpisodes[i].Rating = ar.Rating
|
||||
updated = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
var addedActivity Activity
|
||||
if !found {
|
||||
slog.Debug("Existing watched episode not found, adding as new entry")
|
||||
w.WatchedEpisodes = append(w.WatchedEpisodes, WatchedEpisode{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
SeasonNumber: ar.SeasonNumber,
|
||||
EpisodeNumber: ar.EpisodeNumber,
|
||||
Status: ar.Status,
|
||||
Rating: ar.Rating,
|
||||
})
|
||||
}
|
||||
if resp := db.Save(&w.WatchedEpisodes); resp.Error != nil {
|
||||
slog.Debug("Failed to save watched episode item in db", "error", resp.Error)
|
||||
return WatchedEpisodeAddResponse{}, errors.New("failed to save")
|
||||
}
|
||||
// Add activity
|
||||
if found {
|
||||
// Only add change activity if we actually updated a value
|
||||
// (changing value to same value doesn't count).
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
act := ActivityAddRequest{WatchedID: w.ID, Type: EPISODE_ADDED, Data: string(json)}
|
||||
if ar.addActivity != "" {
|
||||
act.Type = ar.addActivity
|
||||
}
|
||||
if !ar.addActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.addActivityDate
|
||||
}
|
||||
addedActivity, _ = addActivity(db, userId, act)
|
||||
}
|
||||
return WatchedEpisodeAddResponse{
|
||||
WatchedEpisodes: w.WatchedEpisodes,
|
||||
AddedActivity: addedActivity,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove a watched episode
|
||||
func rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "id", id)
|
||||
var watchedEpisode WatchedEpisode
|
||||
resp := db.Clauses(clause.Returning{}).Model(&WatchedEpisode{}).Unscoped().Where("id = ? AND user_id = ?", id, userId).Delete(&watchedEpisode)
|
||||
if resp.Error != nil {
|
||||
slog.Error("Failed when removing a watched episode", "error", resp.Error)
|
||||
return Activity{}, errors.New("failed when removing watched episode")
|
||||
}
|
||||
if resp.RowsAffected == 0 {
|
||||
slog.Error("Failed when removing a watched episode", "error", "zero rows affected")
|
||||
return Activity{}, errors.New("wasn't removed from db.. may not exist")
|
||||
}
|
||||
slog.Debug("rmWatchedEpisode, deleted row", "row", watchedEpisode)
|
||||
if watchedEpisode.ID != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{
|
||||
"season": watchedEpisode.SeasonNumber,
|
||||
"episode": watchedEpisode.EpisodeNumber,
|
||||
"status": watchedEpisode.Status,
|
||||
"rating": watchedEpisode.Rating,
|
||||
})
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedEpisode.WatchedID, Type: EPISODE_REMOVED, Data: string(json)})
|
||||
return addedActivity, nil
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
+33
-16
@@ -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 {
|
||||
@@ -33,7 +36,7 @@ type WatchedSeasonAddResponse struct {
|
||||
}
|
||||
|
||||
// Add/edit a watched season.
|
||||
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at ActivityType) (WatchedSeasonAddResponse, error) {
|
||||
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (WatchedSeasonAddResponse, error) {
|
||||
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w Watched
|
||||
@@ -48,16 +51,19 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at A
|
||||
if w.Content.Type != SHOW {
|
||||
return WatchedSeasonAddResponse{}, errors.New("can't add watched season for non show content")
|
||||
}
|
||||
var found bool
|
||||
found := false
|
||||
updated := false
|
||||
for i, ws := range w.WatchedSeasons {
|
||||
if ws.SeasonNumber == ar.SeasonNumber {
|
||||
slog.Debug("Existing watched season item found, updating existing")
|
||||
found = true
|
||||
if ar.Status != "" {
|
||||
if ar.Status != "" && ar.Status != w.WatchedSeasons[i].Status {
|
||||
w.WatchedSeasons[i].Status = ar.Status
|
||||
updated = true
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
if ar.Rating != 0 && ar.Rating != w.WatchedSeasons[i].Rating {
|
||||
w.WatchedSeasons[i].Rating = ar.Rating
|
||||
updated = true
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -79,17 +85,28 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest, at A
|
||||
}
|
||||
// Add activity
|
||||
if found {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
|
||||
// Only add change activity if we actually updated a value
|
||||
// (changing value to same value doesn't count).
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)})
|
||||
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,
|
||||
|
||||
+111
-34
@@ -1,10 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months } from "./util/helpers";
|
||||
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
export let wListId: number;
|
||||
|
||||
let clickedActivity: Activity;
|
||||
let groupedActivities: { [index: string]: any };
|
||||
let isActivityEditorVisible: boolean;
|
||||
|
||||
$: {
|
||||
groupedActivities = getGroupedActivity(activity);
|
||||
@@ -15,68 +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 ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Synced";
|
||||
case "EPISODE_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
|
||||
}
|
||||
return "Episode Rating Changed";
|
||||
case "EPISODE_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Episode Status Changed";
|
||||
case "EPISODE_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
|
||||
}
|
||||
return "Episode Removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
|
||||
function toFullTitleCase(text: string | undefined) {
|
||||
if (text) {
|
||||
return text
|
||||
.split(" ")
|
||||
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
@@ -107,8 +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}
|
||||
@@ -119,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}
|
||||
@@ -161,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;
|
||||
@@ -178,7 +256,6 @@
|
||||
|
||||
&:last-child {
|
||||
background-color: $accent-color;
|
||||
text-transform: capitalize;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { Icon as IconT } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let text: string;
|
||||
export let icon: IconT = "document";
|
||||
export let filesSelected: (f?: FileList | null) => void;
|
||||
export let allowSelectMultipleFiles = false;
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="drop-file-btn">
|
||||
<button
|
||||
on:click={importFile}
|
||||
on:dragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
on:dragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
on:dragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
on:drop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
filesSelected(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : icon} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
Browse For {text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
</button>
|
||||
<input type="file" multiple={allowSelectMultipleFiles} bind:this={fileInput} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.drop-file-btn {
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
@@ -240,6 +244,52 @@
|
||||
d="M288 256c52.79 0 99.43-49.71 104-110.82 2.27-30.7-7.36-59.33-27.12-80.6C345.33 43.57 318 32 288 32c-30.24 0-57.59 11.5-77 32.38-19.63 21.11-29.2 49.8-27 80.78C188.49 206.28 235.12 256 288 256zM495.38 439.76c-8.44-46.82-34.79-86.15-76.19-113.75C382.42 301.5 335.83 288 288 288s-94.42 13.5-131.19 38c-41.4 27.6-67.75 66.93-76.19 113.75-1.93 10.73.69 21.34 7.19 29.11A30.94 30.94 0 00112 480h352a30.94 30.94 0 0024.21-11.13c6.48-7.77 9.1-18.38 7.17-29.11zM144 216H32a16 16 0 000 32h112a16 16 0 000-32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pencil"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="44"
|
||||
d="M358.62 129.28L86.49 402.08 70 442l39.92-16.49 272.8-272.13-24.1-24.1zM413.07 74.84l-11.79 11.78 24.1 24.1 11.79-11.79a16.51 16.51 0 000-23.34l-.75-.75a16.51 16.51 0 00-23.35 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<circle cx="256" cy="256" r="64" />
|
||||
<path
|
||||
d="M490.84 238.6c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.66 96c-42.52 0-84.33 12.15-124.27 36.11-40.73 24.43-77.63 60.12-109.68 106.07a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.4 76.14 98.28 100.65C162 402 207.9 416 255.66 416c46.71 0 93.81-14.43 136.2-41.72 38.46-24.77 72.72-59.66 99.08-100.92a32.2 32.2 0 00-.1-34.76zM256 352a96 96 0 1196-96 96.11 96.11 0 01-96 96z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "star"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "movary"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform="translate(0.000000,120.000000) scale(0.100000,-0.100000)" fill="currentColor">
|
||||
<path
|
||||
d="M470 1184 c-221 -47 -408 -236 -455 -459 -43 -204 16 -398 164 -545
|
||||
87 -87 164 -131 277 -160 375 -96 742 191 744 581 0 83 -30 202 -72 282 -48
|
||||
92 -165 205 -258 251 -129 62 -264 80 -400 50z m-147 -216 c12 -6 75 -51 140
|
||||
-100 64 -49 123 -91 131 -94 7 -3 67 35 132 84 168 125 169 125 193 112 11 -6
|
||||
25 -19 31 -30 6 -11 10 -141 10 -342 0 -358 -2 -368 -60 -368 -56 0 -60 18
|
||||
-60 288 0 208 -2 243 -15 248 -9 4 -54 -11 -100 -31 l-85 -38 -2 -211 -3 -211
|
||||
-33 0 -34 0 2 201 c0 162 -2 205 -14 216 -22 23 -164 81 -181 75 -13 -6 -15
|
||||
-41 -15 -253 0 -216 -2 -250 -17 -266 -19 -21 -66 -24 -82 -4 -8 10 -12 113
|
||||
-12 357 0 340 1 344 22 362 25 20 24 20 52 5z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -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;
|
||||
|
||||
+56
-14
@@ -1,9 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let rating: number | undefined;
|
||||
export let onChange: (newRating: number) => void;
|
||||
|
||||
let hoveredRating: number | undefined;
|
||||
let shownRating: number | undefined;
|
||||
let ratingContainer: HTMLDivElement;
|
||||
let ratingText: HTMLSpanElement;
|
||||
|
||||
const ratingDesc = [
|
||||
"Apalling",
|
||||
@@ -19,40 +23,67 @@
|
||||
];
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r === rating) return;
|
||||
onChange(r);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (hoveredRating !== undefined) shownRating = hoveredRating;
|
||||
else if (rating !== undefined) shownRating = rating;
|
||||
else shownRating = undefined;
|
||||
else if (rating !== undefined) {
|
||||
shownRating = rating;
|
||||
resetRatingText();
|
||||
} else shownRating = undefined;
|
||||
}
|
||||
|
||||
function handleStarHover(r: number) {
|
||||
function resetRatingText() {
|
||||
if (!ratingText) {
|
||||
console.warn("resetRatingText: ratingText is not defined yet.");
|
||||
return;
|
||||
}
|
||||
if (typeof rating === "number" && rating > 0) {
|
||||
ratingText.innerText = ratingDesc[rating - 1];
|
||||
} else {
|
||||
ratingText.innerText = "Select Your Rating";
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarHover(
|
||||
ev: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
},
|
||||
r: number
|
||||
) {
|
||||
hoveredRating = r;
|
||||
// We set innerText instead of letting svelte update dom for us
|
||||
// since we need the new width of span right now.
|
||||
ratingText.innerText = ratingDesc[r - 1];
|
||||
const start = ratingContainer?.getBoundingClientRect()?.x;
|
||||
const starl = ev?.currentTarget?.getBoundingClientRect()?.left;
|
||||
const rb = ratingText?.getBoundingClientRect();
|
||||
ratingText.style.left = `${starl - start - rb.width / 2 + 11.5}px`;
|
||||
ratingText.style.transform = "unset";
|
||||
}
|
||||
|
||||
function handleStarHoverEnd() {
|
||||
hoveredRating = undefined;
|
||||
ratingText.style.left = "50%";
|
||||
ratingText.style.transform = "translateX(-50%)";
|
||||
resetRatingText();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resetRatingText();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rating-container">
|
||||
<span>
|
||||
{#if typeof hoveredRating === "number"}
|
||||
{ratingDesc[hoveredRating - 1]}
|
||||
{:else if typeof rating === "number" && rating > 0}
|
||||
{ratingDesc[rating - 1]}
|
||||
{:else}
|
||||
Select Your Rating
|
||||
{/if}
|
||||
</span>
|
||||
<div class="rating-container" bind:this={ratingContainer}>
|
||||
<span bind:this={ratingText}></span>
|
||||
<div class="rating">
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{shownRating === v ? ' lit' : ''}"
|
||||
on:click={() => handleStarClick(v)}
|
||||
on:mouseenter={() => handleStarHover(v)}
|
||||
on:mouseenter={(ev) => handleStarHover(ev, v)}
|
||||
on:mouseleave={() => handleStarHoverEnd()}
|
||||
>
|
||||
*
|
||||
@@ -65,6 +96,17 @@
|
||||
.rating-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow: visible;
|
||||
|
||||
& > span {
|
||||
position: relative;
|
||||
transition:
|
||||
left 100ms ease-in,
|
||||
transform 100ms ease-in;
|
||||
max-width: max-content;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
{#if season?.episodes?.length > 0}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<SeasonsListEpisode {ep} />
|
||||
<SeasonsListEpisode {ep} {watchedItem} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
|
||||
@@ -1,17 +1,119 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBSeasonDetailsEpisode } from "@/types";
|
||||
import type {
|
||||
TMDBSeasonDetailsEpisode,
|
||||
WatchedStatus,
|
||||
WatchedEpisodeAddResponse,
|
||||
Watched
|
||||
} from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { userSettings } from "@/store";
|
||||
import PosterRating from "./poster/PosterRating.svelte";
|
||||
import PosterStatus from "./poster/PosterStatus.svelte";
|
||||
import axios from "axios";
|
||||
import { notify } from "./util/notify";
|
||||
import { get } from "svelte/store";
|
||||
import { watchedList } from "@/store";
|
||||
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let ep: TMDBSeasonDetailsEpisode;
|
||||
export let watchedItem: Watched;
|
||||
|
||||
let isHidden = false;
|
||||
|
||||
$: {
|
||||
if (settings) isHidden = settings.hideSpoilers;
|
||||
}
|
||||
|
||||
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
|
||||
watchedId: watchedItem.id,
|
||||
seasonNumber: ep.season_number,
|
||||
episodeNumber: ep.episode_number,
|
||||
status,
|
||||
rating
|
||||
})
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = r.data.watchedEpisodes;
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data.addedActivity);
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
);
|
||||
if (!ws) {
|
||||
notify({
|
||||
text: "Failed to find watched episode id. Please try refreshing.",
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.delete(`/watched/episode/${ws.id}`)
|
||||
.then((r) => {
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === watchedItem.id);
|
||||
if (!wEntry) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: `Request succeeded, but failed to find local data. Please refresh.`,
|
||||
type: "error"
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 200) {
|
||||
wEntry.watchedEpisodes = wEntry.watchedEpisodes?.filter((s) => s.id !== ws.id);
|
||||
if (r.data) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(r.data);
|
||||
} else {
|
||||
wEntry.activity = [r.data];
|
||||
}
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Removed!`, type: "success" });
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateWatchedEpisode(type);
|
||||
}
|
||||
|
||||
function handleStarClick(rating: number) {
|
||||
updateWatchedEpisode(undefined, rating);
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={isHidden ? "dont-spoil" : ""}>
|
||||
@@ -41,6 +143,33 @@
|
||||
</div>
|
||||
<span class="overview">{ep.overview}</span>
|
||||
</div>
|
||||
{#if watchedItem}
|
||||
{@const we = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
)}
|
||||
<div class="status-rating-ctr">
|
||||
<div class="rating" style={"width: 45px"}>
|
||||
<PosterRating
|
||||
rating={we?.rating}
|
||||
btnTooltip={`Episode ${ep.episode_number} Rating`}
|
||||
handleStarClick={(r) => handleStarClick(r)}
|
||||
minimal={true}
|
||||
direction="bot"
|
||||
hideStarWhenRated
|
||||
/>
|
||||
</div>
|
||||
<div class="status">
|
||||
<PosterStatus
|
||||
status={we?.status}
|
||||
btnTooltip={`Episode ${ep.episode_number} Status`}
|
||||
handleStatusClick={(t) => handleStatusClick(t)}
|
||||
direction="bot"
|
||||
width="100%"
|
||||
small
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if isHidden}
|
||||
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
@@ -118,6 +247,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating-ctr {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: column-reverse;
|
||||
gap: 10px;
|
||||
margin-bottom: auto;
|
||||
min-height: 40px;
|
||||
margin-left: auto;
|
||||
|
||||
div {
|
||||
transition: width 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.rating {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
&.status {
|
||||
width: 45px;
|
||||
min-height: 40px;
|
||||
height: 40px;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@@ -185,6 +344,12 @@
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
.status-rating-ctr {
|
||||
flex-flow: row;
|
||||
justify-content: center;
|
||||
margin-left: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
|
||||
+18
-10
@@ -2,44 +2,51 @@
|
||||
import Icon from "./Icon.svelte";
|
||||
import type { WatchedStatus } from "../types";
|
||||
import tooltip from "./actions/tooltip";
|
||||
import { toUnderstandableStatus } from "./util/helpers";
|
||||
|
||||
export let status: WatchedStatus | undefined;
|
||||
export let isForGame: boolean = false;
|
||||
export let onChange: (newStatus: WatchedStatus) => void;
|
||||
|
||||
function handleStatusClick(s: WatchedStatus) {
|
||||
if (s === status) return;
|
||||
onChange(s);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="status">
|
||||
<button
|
||||
class={status && status !== "PLANNED" ? "not-active" : ""}
|
||||
on:click={() => onChange("PLANNED")}
|
||||
use:tooltip={{ text: "Planned", pos: "top" }}
|
||||
on:click={() => handleStatusClick("PLANNED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("PLANNED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="calendar" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "WATCHING" ? "not-active" : ""}
|
||||
on:click={() => onChange("WATCHING")}
|
||||
use:tooltip={{ text: "Watching", pos: "top" }}
|
||||
on:click={() => handleStatusClick("WATCHING")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("WATCHING", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="clock" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "FINISHED" ? "not-active" : ""}
|
||||
on:click={() => onChange("FINISHED")}
|
||||
use:tooltip={{ text: "Finished", pos: "top" }}
|
||||
on:click={() => handleStatusClick("FINISHED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("FINISHED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="check" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "HOLD" ? "not-active" : ""}
|
||||
on:click={() => onChange("HOLD")}
|
||||
use:tooltip={{ text: "On Hold", pos: "top" }}
|
||||
on:click={() => handleStatusClick("HOLD")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("HOLD", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="pause" />
|
||||
</button>
|
||||
<button
|
||||
class={status && status !== "DROPPED" ? "not-active" : ""}
|
||||
on:click={() => onChange("DROPPED")}
|
||||
use:tooltip={{ text: "Dropped", pos: "top" }}
|
||||
on:click={() => handleStatusClick("DROPPED")}
|
||||
use:tooltip={{ text: toUnderstandableStatus("DROPPED", isForGame), pos: "top" }}
|
||||
>
|
||||
<Icon i="thumb-down" />
|
||||
</button>
|
||||
@@ -55,6 +62,7 @@
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+209
-32
@@ -3,21 +3,72 @@
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { activeFilters, activeSort } from "@/store";
|
||||
import type { Watched } from "@/types";
|
||||
import { activeFilters, activeSort, serverFeatures, userSettings } from "@/store";
|
||||
import type { Watched, WatchedEpisode, WatchedSeason } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { seasonAndEpToReadable } from "./util/helpers";
|
||||
|
||||
export let list: Watched[];
|
||||
export let isPublicList: boolean = false;
|
||||
|
||||
$: sort = $activeSort;
|
||||
$: filters = $activeFilters;
|
||||
$: watched = list
|
||||
.sort((a, b) => {
|
||||
$: watched = list;
|
||||
$: settings = $userSettings;
|
||||
$: features = $serverFeatures;
|
||||
|
||||
/**
|
||||
* Checks if content has been watched previously
|
||||
* by analyzing the watched entrys activity (with
|
||||
* the latest AI improvements added in of course.)
|
||||
*/
|
||||
function contentWatchedPreviously(w: Watched) {
|
||||
let wp = false;
|
||||
const relatedActivity = w.activity.filter(
|
||||
(a) =>
|
||||
a.type === "ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_ADDED_WATCHED" ||
|
||||
a.type === "IMPORTED_WATCHED" ||
|
||||
a.type === "STATUS_CHANGED"
|
||||
);
|
||||
for (let i = 0; i < relatedActivity.length; i++) {
|
||||
const ra = relatedActivity[i];
|
||||
if (ra.type === "IMPORTED_ADDED_WATCHED") {
|
||||
wp = true;
|
||||
break;
|
||||
} else if (ra.type === "ADDED_WATCHED" || ra.type === "IMPORTED_WATCHED") {
|
||||
const data = JSON.parse(ra.data);
|
||||
if (data?.status == "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
} else if (ra.type === "STATUS_CHANGED") {
|
||||
if (ra.data === "FINISHED") {
|
||||
wp = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp;
|
||||
}
|
||||
|
||||
// Monsterous code for filters. Soz.
|
||||
$: (watched, filters, sort), filt();
|
||||
|
||||
function filt() {
|
||||
// Set watched to list and sort it.
|
||||
watched = list.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
if (sort[1] === "UP") return a.content.title.localeCompare(b.content.title);
|
||||
else if (sort[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
@@ -27,41 +78,167 @@
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.filter((w) => {
|
||||
if (filters.status.length <= 0 && filters.type.length <= 0) return w;
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
return (
|
||||
filters.status.includes(w.status?.toLowerCase()) && filters.type.includes(w.content.type)
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
if (filters.type.length > 0) {
|
||||
return filters.type.includes(w.content.type);
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
if (filters.status.length > 0) {
|
||||
return filters.status.includes(w.status?.toLowerCase());
|
||||
}
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
// This could probably be simpler but -_-
|
||||
function getLatestWatchedInTv(
|
||||
ws: WatchedSeason[] | undefined,
|
||||
we: WatchedEpisode[] | undefined
|
||||
): string {
|
||||
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let biggestSeasonWatched = -1;
|
||||
let biggestSeasonWatching = -1;
|
||||
if (ws && ws.length > 0) {
|
||||
for (let i = 0; i < ws.length; i++) {
|
||||
const s = ws[i];
|
||||
if (s.status === "WATCHING") {
|
||||
if (s.seasonNumber > biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (s.seasonNumber > biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
|
||||
|
||||
// Look for biggest watched/watching episode in season if any.
|
||||
// Does same thing as above.
|
||||
let episode: WatchedEpisode | undefined;
|
||||
if (we && we.length > 0) {
|
||||
let biggestEpisodeWatched: WatchedEpisode | undefined;
|
||||
let biggestEpisodeWatching: WatchedEpisode | undefined;
|
||||
for (let i = 0; i < we.length; i++) {
|
||||
const s = we[i];
|
||||
if (season >= 0 && s.seasonNumber !== season) continue;
|
||||
if (s.status === "WATCHING") {
|
||||
if (!biggestEpisodeWatching) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (!biggestEpisodeWatched) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (biggestEpisodeWatched || biggestEpisodeWatching) {
|
||||
episode =
|
||||
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
|
||||
}
|
||||
}
|
||||
|
||||
if (season >= 0 && episode) {
|
||||
return seasonAndEpToReadable(season, episode.episodeNumber);
|
||||
} else if (season >= 0) {
|
||||
return `Season ${season}`;
|
||||
} else if (episode) {
|
||||
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
|
||||
} else {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PosterList>
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
/>
|
||||
{#if w.game}
|
||||
<GamePoster
|
||||
id={w.id}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
media={{
|
||||
id: w.game.igdbId,
|
||||
coverId: w.game.coverId,
|
||||
name: w.game.name,
|
||||
summary: w.game.summary,
|
||||
firstReleaseDate: w.game.releaseDate,
|
||||
poster: w.game.poster
|
||||
}}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt
|
||||
}}
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
extraDetails={{
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-list">
|
||||
|
||||
@@ -15,13 +15,14 @@
|
||||
rating: number;
|
||||
}
|
||||
|
||||
export let mediaType: ContentType;
|
||||
export let tmdbId: number;
|
||||
export let mediaType: ContentType | "game";
|
||||
// The tmdbId for movie/tv, igdbId for games.
|
||||
export let mediaId: number;
|
||||
|
||||
let modalShownFor: FollowThoughts | undefined = undefined;
|
||||
|
||||
async function getFollowsThoughts() {
|
||||
return (await axios.get<FollowThoughts[]>(`/follow/thoughts/${mediaType}/${tmdbId}`)).data;
|
||||
return (await axios.get<FollowThoughts[]>(`/follow/thoughts/${mediaType}/${mediaId}`)).data;
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
<script lang="ts">
|
||||
export let homepage: string;
|
||||
export let homepage: string | undefined;
|
||||
export let title: string;
|
||||
export let releaseDate: string;
|
||||
export let releaseYear: string | number;
|
||||
export let voteAverage: number;
|
||||
export let voteCount: number;
|
||||
|
||||
const vote = Math.round(voteAverage * 10) / 10;
|
||||
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
|
||||
const vote = Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10;
|
||||
</script>
|
||||
|
||||
<span class="title-container">
|
||||
<span class="title">
|
||||
<a href={homepage} target="_blank">{title}</a>
|
||||
<span>{new Date(Date.parse(releaseDate)).getFullYear()}</span>
|
||||
{#if homepage}
|
||||
<a href={homepage} target="_blank">{title}</a>
|
||||
{:else}
|
||||
<span class="t">{title}</span>
|
||||
{/if}
|
||||
<span class="year">{releaseYear}</span>
|
||||
</span>
|
||||
<span class="rating" title={`TMDB Rating: ${vote} out of 10 (based on ${voteCount} votes)`}>
|
||||
<span>*</span>
|
||||
@@ -25,7 +30,8 @@
|
||||
gap: 10px;
|
||||
|
||||
.title {
|
||||
a {
|
||||
a,
|
||||
span.t {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
font-size: 30px;
|
||||
@@ -33,7 +39,7 @@
|
||||
padding-right: 3px;
|
||||
}
|
||||
|
||||
span {
|
||||
span.year {
|
||||
font-size: 20px;
|
||||
color: rgba($color: #fff, $alpha: 0.7);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import { wlDetailedView } from "@/store";
|
||||
import type { WLDetailedViewOption } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
function detailClicked(d: WLDetailedViewOption) {
|
||||
let dv = get(wlDetailedView);
|
||||
if (dv.includes(d)) {
|
||||
dv = dv.filter((a) => a !== d);
|
||||
} else {
|
||||
dv.push(d);
|
||||
}
|
||||
wlDetailedView.update((a) => (a = dv));
|
||||
}
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">Shown Details</h4>
|
||||
<button
|
||||
class={`plain ${dve?.includes("statusRating") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("statusRating")}
|
||||
>
|
||||
Status & Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("lastWatched") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("lastWatched")}
|
||||
>
|
||||
Watching Season
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("dateAdded") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("dateAdded")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${dve?.includes("dateModified") ? "on" : ""}`}
|
||||
on:click={() => detailClicked("dateModified")}
|
||||
>
|
||||
Date Modified
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 58px;
|
||||
|
||||
&:before {
|
||||
left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { activeFilters } from "@/store";
|
||||
import { activeFilters, serverFeatures } from "@/store";
|
||||
import type { Filters } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
}
|
||||
|
||||
$: filter = $activeFilters;
|
||||
$: features = $serverFeatures;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
@@ -32,6 +33,14 @@
|
||||
>
|
||||
MOVIE
|
||||
</button>
|
||||
{#if features.games}
|
||||
<button
|
||||
class={`${filter.type.includes("game") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "game")}
|
||||
>
|
||||
GAME
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<h4 class="norm sm-caps">status</h4>
|
||||
<button
|
||||
@@ -45,18 +54,24 @@
|
||||
on:click={() => filterClicked("status", "watching")}
|
||||
>
|
||||
watching
|
||||
{#if features.games}
|
||||
(playing)
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("finished") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "finished")}
|
||||
>
|
||||
finished
|
||||
{#if features.games}
|
||||
(played)
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("hold") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "hold")}
|
||||
>
|
||||
held
|
||||
on hold
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("dropped") ? "on" : ""}`}
|
||||
@@ -69,11 +84,11 @@
|
||||
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 180px;
|
||||
width: 200px;
|
||||
right: 35px;
|
||||
|
||||
&:before {
|
||||
left: 3px;
|
||||
left: 23px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +112,7 @@
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
left: 7.5px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
@@ -117,10 +132,13 @@
|
||||
width: 100%;
|
||||
|
||||
&:first-of-type {
|
||||
border-right: 0;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
border-left: unset;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import type { ExtraDetails, ExtraDetailsGame, Image, WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
getOrdinalSuffix,
|
||||
isTouch,
|
||||
monthsShort,
|
||||
watchedStatuses
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
import { baseURL, removeWatched, updatePlayed } from "../util/api";
|
||||
import { notify } from "../util/notify";
|
||||
import { onMount } from "svelte";
|
||||
import PosterStatus from "./PosterStatus.svelte";
|
||||
import PosterRating from "./PosterRating.svelte";
|
||||
import { wlDetailedView } from "@/store";
|
||||
import { page } from "$app/stores";
|
||||
import { decode } from "blurhash";
|
||||
|
||||
export let id: number | undefined = undefined; // Watched list id
|
||||
export let media: {
|
||||
id: number;
|
||||
coverId: string;
|
||||
firstReleaseDate?: string | number;
|
||||
name: string;
|
||||
summary?: string;
|
||||
poster?: Image;
|
||||
};
|
||||
export let rating: number | undefined = undefined;
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let small = false;
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetailsGame | undefined = undefined;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let bhCanvas: HTMLCanvasElement;
|
||||
|
||||
const title = `${media.name}`;
|
||||
const poster = media.poster?.path
|
||||
? `${baseURL}/${media.poster.path}`
|
||||
: `https://images.igdb.com/igdb/image/upload/t_cover_big/${media.coverId}.jpg`;
|
||||
const link = media.id ? `/game/${media.id}` : undefined;
|
||||
const dateStr = media.firstReleaseDate;
|
||||
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r == rating) return;
|
||||
updatePlayed(media.id, undefined, r);
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
if (!id) {
|
||||
notify({ text: "Content has no watched list id, can't delete.", type: "error" });
|
||||
return;
|
||||
}
|
||||
removeWatched(id);
|
||||
return;
|
||||
}
|
||||
if (type == status) return;
|
||||
updatePlayed(media.id, type);
|
||||
}
|
||||
|
||||
function handleInnerKeyUp(e: KeyboardEvent) {
|
||||
console.log(e.target);
|
||||
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
if (link) {
|
||||
goto(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(e: number) {
|
||||
if (!e) {
|
||||
return "Unknown";
|
||||
}
|
||||
const d = new Date(e);
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
|
||||
d.getFullYear()
|
||||
).substring(2, 4)}`;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (media.poster?.path && media.poster?.blurHash && bhCanvas) {
|
||||
const pixels = decode(media.poster.blurHash, 170, 256);
|
||||
const ctx = bhCanvas.getContext("2d");
|
||||
if (ctx) {
|
||||
const imageData = ctx.createImageData(170, 256);
|
||||
imageData.data.set(pixels);
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- HACK: disabled this issue for now, it should probably be fixed properly -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<li
|
||||
on:mouseenter={(e) => {
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
if (!isTouch()) {
|
||||
posterActive = true;
|
||||
}
|
||||
}}
|
||||
on:focusin={(e) => {
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
}}
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`container${!poster || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
|
||||
bind:this={containerEl}
|
||||
>
|
||||
{#if poster && (media.coverId || media.poster?.path)}
|
||||
{#if media?.poster?.blurHash}
|
||||
<canvas width="170" height="256" bind:this={bhCanvas} class="img-loader" />
|
||||
{:else}
|
||||
<div class="img-loader"></div>
|
||||
{/if}
|
||||
<img
|
||||
loading="lazy"
|
||||
src={poster}
|
||||
alt=""
|
||||
on:load={(e) => {
|
||||
addClassToParent(e, "img-loaded");
|
||||
}}
|
||||
on:error={(e) => {
|
||||
addClassToParent(e, "details-shown");
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
This is one line because svelte leaves whitespace,
|
||||
which causes the :empty css tag to not work.
|
||||
Can be reverted when its possible to trim whitespace in svelte
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
So ye this is probably gonna be one line until the end of time.
|
||||
-->
|
||||
<!-- prettier-ignore -->
|
||||
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
if (posterActive && link) goto(link);
|
||||
}}
|
||||
on:keyup={handleInnerKeyUp}
|
||||
id="ilikemoviessueme"
|
||||
class="inner"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<h2>
|
||||
{#if typeof onClick === "undefined" && link}
|
||||
<a data-sveltekit-preload-data="tap" href={link}>
|
||||
{title}
|
||||
</a>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
{#if year}
|
||||
<time>{year}</time>
|
||||
{/if}
|
||||
</h2>
|
||||
<span>{media.summary}</span>
|
||||
|
||||
{#if !hideButtons}
|
||||
<div class="buttons">
|
||||
<PosterRating {rating} {handleStarClick} {disableInteraction} />
|
||||
<PosterStatus {status} {handleStatusClick} {disableInteraction} isForGame={true} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
li.active {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
background-color: rgb(48, 45, 45);
|
||||
overflow: hidden;
|
||||
flex: 1 1;
|
||||
border-radius: 5px;
|
||||
width: 170px;
|
||||
height: 100%;
|
||||
min-height: 256.367px;
|
||||
position: relative;
|
||||
// aspect-ratio: 2/3;
|
||||
transition: transform 150ms ease;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
&.img-loaded .img-loader,
|
||||
&.details-shown .img-loader {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.img-loader {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.img-loader {
|
||||
background-color: gray;
|
||||
background: linear-gradient(359deg, #5c5c5c, #2c2929, #2c2424);
|
||||
background-size: 400% 400%;
|
||||
animation: imgloader 4s ease infinite;
|
||||
|
||||
@keyframes imgloader {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
50% {
|
||||
background-position: 50% 100%;
|
||||
}
|
||||
100% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
canvas.img-loader {
|
||||
animation: cimgloader 4s ease infinite;
|
||||
|
||||
@keyframes cimgloader {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extra-details {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 160px;
|
||||
color: white;
|
||||
background-color: $poster-extra-detail-bg-color;
|
||||
border-radius: 10px;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
& > div {
|
||||
padding: 8px 3px;
|
||||
|
||||
&:empty {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
width: 15px;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating i:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: transparent;
|
||||
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
time {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
color: white;
|
||||
margin: 5px 0 5px 0;
|
||||
font-size: 9px;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
hyphens: auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
margin-top: auto;
|
||||
gap: 10px;
|
||||
height: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
&.small .inner span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
|
||||
.extra-details {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.small:hover,
|
||||
&.small:focus-within {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:global(.details-shown) {
|
||||
img {
|
||||
filter: blur(4px) grayscale(80%);
|
||||
// This makes the background very dark,
|
||||
// but atleast the text is visible.. may want to change later.
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
|
||||
.inner {
|
||||
color: white;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,10 +1,12 @@
|
||||
<script lang="ts">
|
||||
import type { MediaType, WatchedStatus } from "@/types";
|
||||
import type { ExtraDetails, MediaType, WLDetailedViewOption, WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
getOrdinalSuffix,
|
||||
isTouch,
|
||||
monthsShort,
|
||||
watchedStatuses
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
@@ -14,6 +16,8 @@
|
||||
import { onMount } from "svelte";
|
||||
import PosterStatus from "./PosterStatus.svelte";
|
||||
import PosterRating from "./PosterRating.svelte";
|
||||
import { wlDetailedView } from "@/store";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
export let id: number | undefined = undefined; // Watched list id
|
||||
export let media: {
|
||||
@@ -31,9 +35,12 @@
|
||||
export let small = false;
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetails | undefined = undefined;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
|
||||
@@ -64,6 +71,7 @@
|
||||
removeWatched(id);
|
||||
return;
|
||||
}
|
||||
if (type == status) return;
|
||||
updateWatched(media.id, media.media_type, type);
|
||||
}
|
||||
|
||||
@@ -80,6 +88,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(e: number) {
|
||||
if (!e) {
|
||||
return "Unknown";
|
||||
}
|
||||
const d = new Date(e);
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
|
||||
d.getFullYear()
|
||||
).substring(2, 4)}`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
@@ -104,8 +122,11 @@
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}`}
|
||||
>
|
||||
<div class={`container${!poster ? " details-shown" : ""}`} bind:this={containerEl}>
|
||||
{#if poster}
|
||||
<div
|
||||
class={`container${!poster || !media.poster_path ? " details-shown" : ""}`}
|
||||
bind:this={containerEl}
|
||||
>
|
||||
{#if poster && media.poster_path}
|
||||
<div class="img-loader" />
|
||||
<img
|
||||
loading="lazy"
|
||||
@@ -119,6 +140,19 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
This is one line because svelte leaves whitespace,
|
||||
which causes the :empty css tag to not work.
|
||||
Can be reverted when its possible to trim whitespace in svelte
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
So ye this is probably gonna be one line until the end of time.
|
||||
-->
|
||||
<!-- prettier-ignore -->
|
||||
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if extraDetails.lastWatched && dve.includes("lastWatched")}<span title="Latest season watched"><i><Icon i="play" wh={15} /></i><span>{extraDetails.lastWatched}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
if (typeof onClick !== "undefined") {
|
||||
@@ -208,6 +242,54 @@
|
||||
}
|
||||
}
|
||||
|
||||
.extra-details {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 160px;
|
||||
color: white;
|
||||
background-color: $poster-extra-detail-bg-color;
|
||||
border-radius: 10px;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
& > div {
|
||||
padding: 8px 3px;
|
||||
|
||||
&:empty {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
width: 15px;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating i:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -269,6 +351,10 @@
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
|
||||
.extra-details {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.small:hover,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
export let minimal = false;
|
||||
export let direction: "top" | "bot" = "top";
|
||||
export let btnTooltip: string = "";
|
||||
export let hideStarWhenRated = false;
|
||||
|
||||
let ratingsShown = false;
|
||||
</script>
|
||||
@@ -27,7 +28,7 @@
|
||||
}}
|
||||
use:tooltip={{ text: btnTooltip, pos: "top", condition: !!btnTooltip && !ratingsShown }}
|
||||
>
|
||||
<span>*</span>
|
||||
<span style={hideStarWhenRated && rating ? "display: none" : ""}>*</span>
|
||||
{#if !minimal}
|
||||
<span class={!rating && disableInteraction ? "unrated-text" : ""}>
|
||||
{rating ? rating : disableInteraction ? "Unrated" : "Rate"}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import type { WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import tooltip from "../actions/tooltip";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
import { toUnderstandableStatus, watchedStatuses } from "../util/helpers";
|
||||
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let handleStatusClick: (status: WatchedStatus | "DELETE") => void;
|
||||
@@ -11,6 +11,7 @@
|
||||
export let small = false;
|
||||
export let btnTooltip: string = "";
|
||||
export let disableInteraction: boolean = false;
|
||||
export let isForGame = false;
|
||||
|
||||
let statusesShown = false;
|
||||
</script>
|
||||
@@ -39,7 +40,9 @@
|
||||
<button
|
||||
class="plain{status && status !== statusName ? ' not-active' : ''}"
|
||||
on:click={() => handleStatusClick(statusName)}
|
||||
use:tooltip={{ text: statusName }}
|
||||
use:tooltip={{
|
||||
text: toUnderstandableStatus(statusName, isForGame)
|
||||
}}
|
||||
>
|
||||
<Icon i={icon} />
|
||||
</button>
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
<script lang="ts">
|
||||
import tooltip from "../actions/tooltip";
|
||||
|
||||
export let name: string;
|
||||
export let value: string | number;
|
||||
export let large = false;
|
||||
export let href: string | undefined = undefined;
|
||||
export let disc: string | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<a {href}>
|
||||
{#if disc}
|
||||
<div class="disclaimer" use:tooltip={{ text: disc, pos: "top" }}>*</div>
|
||||
{/if}
|
||||
<span class={large ? "large" : ""}>{value}</span>
|
||||
<span>{name}</span>
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
flex-grow: 1;
|
||||
@@ -20,7 +27,7 @@
|
||||
background-color: $accent-color;
|
||||
border-radius: 8px;
|
||||
|
||||
> span:first-child {
|
||||
> span:first-of-type {
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
|
||||
@@ -32,5 +39,13 @@
|
||||
> span:last-child {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 8px;
|
||||
font-weight: bold;
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+156
-36
@@ -9,19 +9,63 @@ import {
|
||||
type WatchedUpdateRequest,
|
||||
type WatchedUpdateResponse,
|
||||
type UserSettings,
|
||||
type Follow
|
||||
type Follow,
|
||||
type PlayedAddRequest,
|
||||
type ActivityUpdateRequest
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { get } from "svelte/store";
|
||||
import { notify } from "./notify";
|
||||
import { notify, unNotify } from "./notify";
|
||||
const { MODE } = import.meta.env;
|
||||
|
||||
export const baseURL = MODE === "development" ? "http://127.0.0.1:3080/api" : "/api";
|
||||
|
||||
/**
|
||||
*
|
||||
* Updates watched item with new status, rating or thoughts.
|
||||
*/
|
||||
function _updateWatched(
|
||||
wEntry: Watched,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
if (!status && !rating && typeof thoughts === "undefined") return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
axios
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (resp?.data?.newActivity) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update watched show/movie.
|
||||
* @param contentId TMDB ID
|
||||
* @param contentType
|
||||
* @param contentType show/movie
|
||||
* @param status
|
||||
* @param rating
|
||||
* @returns
|
||||
@@ -36,40 +80,10 @@ export function updateWatched(
|
||||
// If item is already in watched store, run update request instead
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find(
|
||||
(w) => w.content.tmdbId === contentId && w.content.type === contentType
|
||||
(w) => w.content?.tmdbId === contentId && w.content?.type === contentType
|
||||
);
|
||||
if (wEntry?.id) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
if (!status && !rating && typeof thoughts === "undefined") return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
if (rating) obj.rating = rating;
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
axios
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
|
||||
if (resp?.data?.newActivity) {
|
||||
if (wEntry.activity?.length > 0) {
|
||||
wEntry.activity.push(resp.data.newActivity);
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
_updateWatched(wEntry, status, rating, thoughts);
|
||||
return;
|
||||
}
|
||||
// Add new watched item
|
||||
@@ -120,6 +134,88 @@ export function removeWatched(id: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePlayed(
|
||||
igdbId: number,
|
||||
status?: WatchedStatus,
|
||||
rating?: number,
|
||||
thoughts?: string
|
||||
) {
|
||||
// If item is already in watched store, run update request instead
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.game?.igdbId === igdbId);
|
||||
if (wEntry?.id) {
|
||||
_updateWatched(wEntry, status, rating, thoughts);
|
||||
return;
|
||||
}
|
||||
// Add new played item
|
||||
const nid = notify({ text: `Adding`, type: "loading" });
|
||||
axios
|
||||
.post("/game/played", {
|
||||
igdbId,
|
||||
rating,
|
||||
status
|
||||
} as PlayedAddRequest)
|
||||
.then((resp) => {
|
||||
console.log("Added watched(played) game:", resp.data);
|
||||
wList.push(resp.data as Watched);
|
||||
watchedList.update(() => wList);
|
||||
notify({ id: nid, text: `Added!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ id: nid, text: "Failed To Add!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -170,6 +266,30 @@ export function updateUserSetting<K extends keyof UserSettings>(
|
||||
});
|
||||
}
|
||||
|
||||
export function changeUserPassword(
|
||||
oldPassword: string,
|
||||
newPassword: string,
|
||||
done?: (errMsg?: string) => void
|
||||
) {
|
||||
const nid = notify({ type: "loading", text: "Changing Password" });
|
||||
axios
|
||||
.post("/auth/change_password", { oldPassword, newPassword })
|
||||
.then((r) => {
|
||||
if (r.status === 200) {
|
||||
notify({ id: nid, type: "success", text: "Password Changed" });
|
||||
if (typeof done !== "undefined") done();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const errMsg = err?.response?.data?.error
|
||||
? err.response.data.error
|
||||
: "Couldn't Change Password";
|
||||
console.error("Change Password Form - Failed to change password on the server", err);
|
||||
unNotify(nid);
|
||||
if (typeof done !== "undefined") done(errMsg);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update serverFeatues store with fresh data.
|
||||
*/
|
||||
|
||||
+52
-2
@@ -57,9 +57,9 @@ export function isTouch() {
|
||||
// Not passing wList from #each loop caused it not to have reactivity.
|
||||
// Passing it through must allow it to recognize it as a dependency?
|
||||
export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.content.tmdbId === wid && wl.content.type === wtype);
|
||||
const wel = list.find((wl) => wl.content?.tmdbId === wid && wl.content?.type === wtype);
|
||||
if (!wel) return {};
|
||||
console.log(wid, wtype, wel?.content.title, wel?.status, wel?.rating);
|
||||
console.log(wid, wtype, wel?.content?.title, wel?.status, wel?.rating);
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
@@ -67,6 +67,48 @@ export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Wat
|
||||
};
|
||||
}
|
||||
|
||||
export function getPlayedDependedProps(wid: number, list: Watched[]) {
|
||||
const wel = list.find((wl) => wl.game?.igdbId === wid);
|
||||
if (!wel) return {};
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a watched status into understandable text
|
||||
* depending on if the status is for a game or not.
|
||||
*/
|
||||
/**
|
||||
* Turns a WatchedStatus into readable and context aware text.
|
||||
* Watched statuses can be used normally for movies/tv, but
|
||||
* for games, we want to transform the status to make more sense.
|
||||
* ex: 'finished' would become 'played' for games, but remain
|
||||
* unmodified for series/movies.
|
||||
*
|
||||
* This is only for use when displaying a status in ui for a user
|
||||
* to read, should **never** be involved in logic (comparing
|
||||
* statuses for example).
|
||||
*
|
||||
* @param s The watched status.
|
||||
* @param isForGame If this status is being displayed for a game or not.
|
||||
*/
|
||||
export function toUnderstandableStatus(s: WatchedStatus, isForGame: boolean) {
|
||||
if (isForGame) {
|
||||
if (s === "FINISHED") {
|
||||
return "played";
|
||||
} else if (s === "WATCHING") {
|
||||
return "playing";
|
||||
}
|
||||
}
|
||||
if (s === "HOLD") {
|
||||
return "on hold";
|
||||
}
|
||||
return s?.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a class to the parent node of a clicked element.
|
||||
* @param e Event with currentTarget.
|
||||
@@ -188,3 +230,11 @@ export function userHasPermission(perms: UserPermission, reqPerm: UserPermission
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes season and episode number to make a quickly understandable
|
||||
* string in this format S1E1.
|
||||
*/
|
||||
export function seasonAndEpToReadable(season: number | undefined, episode: number | undefined) {
|
||||
return `S${typeof season === "number" ? String(season) : "(unknown)"}E${typeof episode === "number" ? String(episode) : "(unknown)"}`;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+7
-2
@@ -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;
|
||||
@@ -156,6 +160,7 @@
|
||||
color: white;
|
||||
border: 1px solid white;
|
||||
border-radius: 6px;
|
||||
text-transform: capitalize;
|
||||
z-index: 99999;
|
||||
transition:
|
||||
top 100ms ease,
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import tooltip from "@/lib/actions/tooltip";
|
||||
import DetailedMenu from "@/lib/nav/DetailedMenu.svelte";
|
||||
import FilterMenu from "@/lib/nav/FilterMenu.svelte";
|
||||
import FollowingMenu from "@/lib/nav/FollowingMenu.svelte";
|
||||
import SortMenu from "@/lib/nav/SortMenu.svelte";
|
||||
@@ -33,6 +34,7 @@
|
||||
let filterMenuShown = false;
|
||||
let sortMenuShown = false;
|
||||
let followingMenuShown = false;
|
||||
let detailedMenuShown = false;
|
||||
|
||||
$: settings = $userSettings;
|
||||
$: user = $userInfo;
|
||||
@@ -154,6 +156,7 @@
|
||||
if (except !== "filter") filterMenuShown = false;
|
||||
if (except !== "sort") sortMenuShown = false;
|
||||
if (except !== "following") followingMenuShown = false;
|
||||
if (except !== "detailed") detailedMenuShown = false;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
@@ -183,6 +186,25 @@
|
||||
</a>
|
||||
<input type="text" placeholder="Search" bind:value={$searchQuery} on:keydown={handleSearch} />
|
||||
<div class="btns">
|
||||
<!-- Detailed posters only supported on own watched list currently -->
|
||||
{#if $page.url?.pathname === "/"}
|
||||
<button
|
||||
class="plain other detailedView"
|
||||
on:click={() => {
|
||||
closeAllSubMenus("detailed");
|
||||
detailedMenuShown = !detailedMenuShown;
|
||||
}}
|
||||
use:tooltip={{ text: "Detailed View", pos: "bot", condition: !detailedMenuShown }}
|
||||
>
|
||||
<Icon i="eye" />
|
||||
{#if $activeFilters?.type?.length > 0 || $activeFilters?.status?.length > 0}
|
||||
<div class="indicator"></div>
|
||||
{/if}
|
||||
</button>
|
||||
{#if detailedMenuShown}
|
||||
<DetailedMenu />
|
||||
{/if}
|
||||
{/if}
|
||||
<!-- Show on watched list and shared/followed watched lists -->
|
||||
{#if $page.url?.pathname === "/" || $page.url?.pathname.includes("/lists/")}
|
||||
<button
|
||||
@@ -369,6 +391,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
button.detailedView {
|
||||
margin-right: 15px;
|
||||
}
|
||||
|
||||
button.filter {
|
||||
margin-right: 15px;
|
||||
&:hover,
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
<script lang="ts">
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
|
||||
import Rating from "@/lib/Rating.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import Status from "@/lib/Status.svelte";
|
||||
import HorizontalList from "@/lib/HorizontalList.svelte";
|
||||
import { serverFeatures, watchedList } from "@/store";
|
||||
import { GameWebsiteCategory, type GameDetailsResponse, type WatchedStatus } from "@/types";
|
||||
import axios from "axios";
|
||||
import Activity from "@/lib/Activity.svelte";
|
||||
import Title from "@/lib/content/Title.svelte";
|
||||
import VideoEmbedModal from "@/lib/content/VideoEmbedModal.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { page } from "$app/stores";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import FollowedThoughts from "@/lib/content/FollowedThoughts.svelte";
|
||||
import { updatePlayed } from "@/lib/util/api.js";
|
||||
import GamePoster from "@/lib/poster/GamePoster.svelte";
|
||||
import { getPlayedDependedProps } from "@/lib/util/helpers";
|
||||
|
||||
export let data;
|
||||
|
||||
let trailer: string | undefined;
|
||||
let trailerShown = false;
|
||||
|
||||
$: wList = $watchedList;
|
||||
$: wListItem = $watchedList.find((w) => w.game?.igdbId === data.gameId);
|
||||
|
||||
let gameId: number | undefined;
|
||||
let game: GameDetailsResponse | undefined;
|
||||
let pageError: Error | undefined;
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = page.subscribe((value) => {
|
||||
console.log(value);
|
||||
const params = value.params;
|
||||
if (params && params.id) {
|
||||
gameId = Number(params.id);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
$: {
|
||||
(async () => {
|
||||
try {
|
||||
game = undefined;
|
||||
pageError = undefined;
|
||||
if (!gameId) {
|
||||
return;
|
||||
}
|
||||
const data = (await axios.get(`/game/${gameId}`)).data as GameDetailsResponse;
|
||||
if (data.videos?.length > 0) {
|
||||
const t = data.videos.find((v) => v.name?.toLowerCase() === "trailer");
|
||||
// Doc says the video_id is "usually youtube", so we are gonna go with that assumption too ( 0 _ 0 )
|
||||
if (t?.video_id) {
|
||||
trailer = `https://www.youtube.com/embed/${t?.video_id}`;
|
||||
}
|
||||
}
|
||||
game = data;
|
||||
} catch (err: any) {
|
||||
game = undefined;
|
||||
pageError = err;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
function contentChanged(newStatus?: WatchedStatus, newRating?: number, newThoughts?: string) {
|
||||
if (!gameId) {
|
||||
console.error("contentChanged: no gameId");
|
||||
return;
|
||||
}
|
||||
updatePlayed(gameId, newStatus, newRating, newThoughts);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if pageError}
|
||||
<PageError pretty="Failed to load game!" error={pageError} />
|
||||
{:else if !game}
|
||||
<Spinner />
|
||||
{:else if Object.keys(game).length > 0}
|
||||
<div>
|
||||
<div class="content">
|
||||
{#if game?.artworks?.length > 0}
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://images.igdb.com/igdb/image/upload/t_720p/" +
|
||||
game.artworks[Math.floor(Math.random() * game.artworks.length)].image_id +
|
||||
".jpg"}
|
||||
alt=""
|
||||
/>
|
||||
{:else if game?.cover?.image_id}
|
||||
<!-- Fallback to using the game cover for backdrop if there is no artwork -->
|
||||
<img
|
||||
class="backdrop"
|
||||
src={"https://images.igdb.com/igdb/image/upload/t_720p/" + game.cover.image_id + ".jpg"}
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
<div class="vignette" />
|
||||
|
||||
<div class="details-container">
|
||||
<img
|
||||
class="poster"
|
||||
src={"https://images.igdb.com/igdb/image/upload/t_cover_big/" +
|
||||
game.cover.image_id +
|
||||
".jpg"}
|
||||
alt=""
|
||||
/>
|
||||
|
||||
<div class="details">
|
||||
<Title
|
||||
title={game.name}
|
||||
homepage={game.websites?.find((w) => w.category == GameWebsiteCategory.Official)?.url}
|
||||
releaseYear={new Date(game.first_release_date).getFullYear()}
|
||||
voteAverage={game.rating}
|
||||
voteCount={game.rating_count}
|
||||
/>
|
||||
|
||||
<span class="quick-info">
|
||||
{#if game.genres?.length > 0}
|
||||
<div>
|
||||
{#each game.genres as g, i}
|
||||
<span>{g.name}{i !== game.genres.length - 1 ? ", " : ""}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<span>Unknown Genres</span>
|
||||
{/if}
|
||||
<span></span>
|
||||
<div>
|
||||
{#if game.game_modes?.length > 0}
|
||||
{#each game.game_modes as g, i}
|
||||
<span>{g.name}{i !== game.game_modes.length - 1 ? ", " : ""}</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span>Unknown Game Modes</span>
|
||||
{/if}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
<span style="font-weight: bold; font-size: 14px;">Overview</span>
|
||||
<p>{game.summary}</p>
|
||||
|
||||
<div class="btns">
|
||||
{#if trailer}
|
||||
<button on:click={() => (trailerShown = !trailerShown)}>View Trailer</button>
|
||||
{#if trailerShown}
|
||||
<VideoEmbedModal embed={trailer} closed={() => (trailerShown = false)} />
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- <ProvidersList providers={game["watch/providers"]} /> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page">
|
||||
<div class="review">
|
||||
<Rating rating={wListItem?.rating} onChange={(n) => contentChanged(undefined, n)} />
|
||||
<Status status={wListItem?.status} isForGame={true} onChange={(n) => contentChanged(n)} />
|
||||
{#if wListItem}
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${game.name}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if gameId}
|
||||
<FollowedThoughts mediaType="game" mediaId={gameId} />
|
||||
{/if}
|
||||
|
||||
{#if game.similar_games?.length > 0}
|
||||
<HorizontalList title="Similar">
|
||||
{#each game.similar_games as g}
|
||||
<GamePoster
|
||||
media={{
|
||||
id: g.id,
|
||||
coverId: g.cover.image_id,
|
||||
name: g.name,
|
||||
summary: g.summary,
|
||||
firstReleaseDate: g.first_release_date
|
||||
}}
|
||||
{...getPlayedDependedProps(g.id, wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
{/if}
|
||||
|
||||
{#if wListItem}
|
||||
<Activity wListId={wListItem.id} activity={wListItem.activity} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Error error="Game not found" pretty="Game not found" />
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
position: relative;
|
||||
color: white;
|
||||
|
||||
img.backdrop {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: $backdrop-filter;
|
||||
mix-blend-mode: $backdrop-mix-blend-mode;
|
||||
mask-image: $backdrop-mask-image;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba($color: #000000, $alpha: 0.7);
|
||||
z-index: -1;
|
||||
mask-image: $backdrop-mask-image;
|
||||
}
|
||||
|
||||
.details-container {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 35px;
|
||||
max-width: 1100px;
|
||||
padding: 40px 80px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
img.poster {
|
||||
width: 235px;
|
||||
height: 100%;
|
||||
box-shadow: 0px 0px 14px -4px #9c8080;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.details {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
|
||||
.quick-info {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
|
||||
a.btn,
|
||||
button {
|
||||
max-width: fit-content;
|
||||
overflow: hidden;
|
||||
animation: 50ms cubic-bezier(0.86, 0, 0.07, 1) forwards otherbtn;
|
||||
white-space: nowrap;
|
||||
gap: 6px;
|
||||
justify-content: flex-start;
|
||||
font-size: 14px;
|
||||
|
||||
@keyframes otherbtn {
|
||||
from {
|
||||
width: 0px;
|
||||
}
|
||||
to {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 700px) {
|
||||
padding: 40px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.page {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
gap: 30px;
|
||||
padding: 20px 50px;
|
||||
max-width: 1200px;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.review {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
max-width: 380px;
|
||||
}
|
||||
|
||||
.creators {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 35px;
|
||||
margin: 10px 60px;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 150px;
|
||||
|
||||
span:first-child {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,15 @@
|
||||
import { error } from "@sveltejs/kit";
|
||||
import type { PageLoad } from "../../search/[query]/$types";
|
||||
|
||||
export const load = (async ({ params }) => {
|
||||
const { id } = params;
|
||||
|
||||
if (!id) {
|
||||
error(400);
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
gameId: Number(id)
|
||||
};
|
||||
}) satisfies PageLoad;
|
||||
@@ -7,14 +7,15 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import DropFileButton from "@/lib/DropFileButton.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { importedList } from "@/store";
|
||||
import { onMount } from "svelte";
|
||||
import papa from "papaparse";
|
||||
import type { ImportedList, MovaryHistory, MovaryRatings, MovaryWatchlist } from "@/types";
|
||||
import { json } from "@sveltejs/kit";
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
let isLoading = false;
|
||||
|
||||
@@ -51,13 +52,15 @@
|
||||
return;
|
||||
}
|
||||
const r = new FileReader();
|
||||
let type: "text-list" | "tmdb" = "text-list";
|
||||
if (file.type === "text/csv") type = "tmdb";
|
||||
r.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
if (r.result) {
|
||||
importedList.set({
|
||||
file,
|
||||
data: r.result.toString()
|
||||
data: r.result.toString(),
|
||||
type
|
||||
});
|
||||
goto("/import/process");
|
||||
}
|
||||
@@ -72,19 +75,169 @@
|
||||
}
|
||||
}
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
async function readFile(fr: FileReader, file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const res = () => {
|
||||
fr.removeEventListener("load", res);
|
||||
fr.removeEventListener("error", rej);
|
||||
if (fr.result) {
|
||||
resolve(fr.result.toString());
|
||||
} else {
|
||||
reject("no result");
|
||||
}
|
||||
};
|
||||
const rej = () => {
|
||||
fr.removeEventListener("load", res);
|
||||
fr.removeEventListener("error", rej);
|
||||
reject();
|
||||
};
|
||||
fr.addEventListener("load", res);
|
||||
fr.addEventListener("error", rej);
|
||||
fr.readAsText(file);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process movary import files.
|
||||
*
|
||||
* Movary exports 3 different files:
|
||||
*
|
||||
* - watchlist.csv = Planned movies.
|
||||
* - history.csv = Watched movies, movie can be watched multiple times.
|
||||
* - ratings.csv = Ratings for movies. One rating per movie.
|
||||
*
|
||||
* Export types explained better here:
|
||||
* https://github.com/sbondCo/Watcharr/issues/332#issuecomment-1920662244
|
||||
*/
|
||||
async function processFilesMovary(files?: FileList | null) {
|
||||
try {
|
||||
console.log("processFilesMovary", files);
|
||||
if (!files || files?.length <= 0) {
|
||||
console.error("processFilesMovary", "No files to process!");
|
||||
notify({
|
||||
type: "error",
|
||||
text: "File not found in dropped items. Please try again or refresh.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
if (files.length !== 3) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "You must select or drop 3 files: history.csv, ratings.csv and watchlist.csv.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
// Read file data into strings
|
||||
let history: string | undefined;
|
||||
let ratings: string | undefined;
|
||||
let watchlist: string | undefined;
|
||||
const r = new FileReader();
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const f = files[i];
|
||||
if (f.name === "history.csv") {
|
||||
history = await readFile(r, f);
|
||||
} else if (f.name === "ratings.csv") {
|
||||
ratings = await readFile(r, f);
|
||||
} else if (f.name === "watchlist.csv") {
|
||||
watchlist = await readFile(r, f);
|
||||
}
|
||||
}
|
||||
if (!history || !ratings || !watchlist) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to read history, ratings or watchlist. Ensure you have attached 3 files: history.csv, ratings.csv and watchlist.csv.",
|
||||
time: 6000
|
||||
});
|
||||
isDragOver = false;
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
console.log("loaded all files");
|
||||
// Convert csv strings into json
|
||||
const historyJson = papa.parse<MovaryHistory>(history.trim(), { header: true });
|
||||
const ratingsJson = papa.parse<MovaryRatings>(ratings.trim(), { header: true });
|
||||
const watchlistJson = papa.parse<MovaryWatchlist>(watchlist.trim(), { header: true });
|
||||
// Build toImport array
|
||||
const toImport: ImportedList[] = [];
|
||||
// Add all history movies (watched). There can be multiple entries for each movie.
|
||||
for (let i = 0; i < historyJson.data.length; i++) {
|
||||
const h = historyJson.data[i];
|
||||
// Skip if no tmdb id.
|
||||
if (!h.tmdbId) {
|
||||
continue;
|
||||
}
|
||||
// Skip if already added. The first time it is added we get all info needed from other entries.
|
||||
if (toImport.filter((ti) => ti.tmdbId == Number(h.tmdbId)).length > 0) {
|
||||
continue;
|
||||
}
|
||||
const ratingsEntry = ratingsJson.data.find((r) => r.tmdbId == h.tmdbId);
|
||||
const t: ImportedList = {
|
||||
name: h.title,
|
||||
tmdbId: Number(h.tmdbId),
|
||||
status: "FINISHED",
|
||||
type: "movie", // movary only supports movies
|
||||
datesWatched: [],
|
||||
thoughts: ""
|
||||
};
|
||||
// Movie can be watched more than once, get all entries to store all watch dates.
|
||||
const allEntries = historyJson.data.filter((he) => he.tmdbId === h.tmdbId);
|
||||
for (let i = 0; i < allEntries.length; i++) {
|
||||
const e = allEntries[i];
|
||||
if (e.watchedAt) {
|
||||
t.datesWatched?.push(new Date(e.watchedAt));
|
||||
}
|
||||
if (e.comment) {
|
||||
t.thoughts += e.comment + "\n";
|
||||
}
|
||||
}
|
||||
if (h.year) {
|
||||
t.year = h.year;
|
||||
}
|
||||
if (ratingsEntry && ratingsEntry?.userRating) {
|
||||
t.rating = Number(ratingsEntry.userRating);
|
||||
}
|
||||
toImport.push(t);
|
||||
}
|
||||
// Add all watchlist movies (planned).
|
||||
for (let i = 0; i < watchlistJson.data.length; i++) {
|
||||
const wl = watchlistJson.data[i];
|
||||
const existing = toImport.find((ti) => ti.tmdbId == Number(wl.tmdbId));
|
||||
// If already exists in toImport, simply update status to PLANNED.
|
||||
// The movie must have been completed in past, but added back to
|
||||
// the users movary watch list as they are planning to watch it again.
|
||||
if (existing) {
|
||||
existing.status = "PLANNED";
|
||||
continue;
|
||||
}
|
||||
toImport.push({
|
||||
name: wl.title,
|
||||
tmdbId: Number(wl.tmdbId),
|
||||
status: "PLANNED",
|
||||
type: "movie" // movary only supports movies
|
||||
});
|
||||
}
|
||||
console.log("toImport:", toImport);
|
||||
importedList.set({
|
||||
data: JSON.stringify(toImport),
|
||||
type: "movary"
|
||||
});
|
||||
goto("/import/process");
|
||||
} catch (err) {
|
||||
isLoading = false;
|
||||
notify({ type: "error", text: "Failed to read files!" });
|
||||
console.error("import: Failed to read files!", err);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem("token")) {
|
||||
goto("/login");
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", (ev) => {
|
||||
processFiles(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -95,56 +248,26 @@
|
||||
<h2>Import Your Watchlist</h2>
|
||||
<h5 class="norm">beta</h5>
|
||||
</span>
|
||||
<h4 class="norm">Currently txt and csv (TMDb export) files are supported.</h4>
|
||||
<!-- <h4 class="norm">Currently txt and csv (TMDb export) files are supported.</h4> -->
|
||||
</div>
|
||||
<div class="big-btns">
|
||||
{#if isLoading}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<button
|
||||
on:click={importFile}
|
||||
on:dragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
on:dragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
on:dragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
on:drop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
processFiles(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : "document"} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import
|
||||
{:else}
|
||||
Browse
|
||||
{/if}
|
||||
</h4>
|
||||
<!-- <h5 class="norm">Or Drag And Drop</h5> -->
|
||||
</div>
|
||||
</button>
|
||||
<DropFileButton
|
||||
text=".txt list or .csv TMDb Export"
|
||||
filesSelected={(f) => processFiles(f)}
|
||||
/>
|
||||
|
||||
<DropFileButton
|
||||
icon="movary"
|
||||
text="Movary Exports"
|
||||
filesSelected={(f) => processFilesMovary(f)}
|
||||
allowSelectMultipleFiles
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" bind:this={fileInput} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -162,6 +285,14 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.big-btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -170,61 +301,5 @@
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.big-btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
let rList: ImportedList[] = [];
|
||||
let isImporting = false;
|
||||
let cancelled = false;
|
||||
let importText = "";
|
||||
|
||||
onDestroy(() => {
|
||||
cancelled = true;
|
||||
@@ -57,7 +58,8 @@
|
||||
return;
|
||||
}
|
||||
console.log("getList", list);
|
||||
if (list?.file.type === "text/plain") {
|
||||
if (list?.type === "text-list") {
|
||||
importText = "Text List";
|
||||
// Regex to match a year in between brackets,
|
||||
// which we assume is the release year of content.
|
||||
const yearRegex = new RegExp(/\([0-9]{4}\)/);
|
||||
@@ -74,7 +76,8 @@
|
||||
rList.push(l);
|
||||
}
|
||||
}
|
||||
} else if (list?.file.type === "text/csv") {
|
||||
} else if (list?.type === "tmdb") {
|
||||
importText = "TMDB";
|
||||
const s = papa.parse(list.data.trim(), { header: true });
|
||||
console.debug("parsed csv", s);
|
||||
for (let i = 0; i < s.data.length; i++) {
|
||||
@@ -113,6 +116,19 @@
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (list?.type === "movary") {
|
||||
importText = "Movary";
|
||||
try {
|
||||
const s = JSON.parse(list.data);
|
||||
// Builds imported list in previous step for ease.
|
||||
rList = s;
|
||||
} catch (err) {
|
||||
console.error("Movary import processing failed!", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Processing failed!. Please report this issue if it persists."
|
||||
});
|
||||
}
|
||||
}
|
||||
// TODO: remove duplicate names in list
|
||||
return list;
|
||||
@@ -222,9 +238,9 @@
|
||||
const w = resp.data.watchedEntry;
|
||||
if (w) {
|
||||
const release =
|
||||
w.content.type === "movie" ? w.content.release_date : w.content.first_air_date;
|
||||
w.content?.type === "movie" ? w.content?.release_date : w.content?.first_air_date;
|
||||
if (release) item.year = String(new Date(Date.parse(release)).getFullYear());
|
||||
item.type = w.content.type;
|
||||
item.type = w.content?.type;
|
||||
wList.push(w);
|
||||
watchedList.update(() => wList);
|
||||
}
|
||||
@@ -245,7 +261,7 @@
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
{#if rList}
|
||||
<h2>Importing {list?.file.name ? list.file.name : ""}</h2>
|
||||
<h2>Importing {importText ? `From ${importText}` : ""}</h2>
|
||||
<h5 class="norm">
|
||||
{#if !isImporting}
|
||||
Review your imported list and fix any problems.
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
let jellyfinUrl: string | undefined;
|
||||
|
||||
$: wListItem = $watchedList.find(
|
||||
(w) => w.content.type === "movie" && w.content.tmdbId === data.movieId
|
||||
(w) => w.content?.type === "movie" && w.content?.tmdbId === data.movieId
|
||||
);
|
||||
|
||||
let movieId: number | undefined;
|
||||
@@ -121,7 +121,7 @@
|
||||
<Title
|
||||
title={movie.title}
|
||||
homepage={movie.homepage}
|
||||
releaseDate={movie.release_date}
|
||||
releaseYear={new Date(Date.parse(movie.release_date)).getFullYear()}
|
||||
voteAverage={movie.vote_average}
|
||||
voteCount={movie.vote_count}
|
||||
/>
|
||||
@@ -181,7 +181,10 @@
|
||||
placeholder={`My thoughts on ${movie.title}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
console.log(e.currentTarget?.value);
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
}}
|
||||
/>
|
||||
@@ -189,7 +192,7 @@
|
||||
</div>
|
||||
|
||||
{#if movieId}
|
||||
<FollowedThoughts mediaType="movie" tmdbId={movieId} />
|
||||
<FollowedThoughts mediaType="movie" mediaId={movieId} />
|
||||
{/if}
|
||||
|
||||
{#await getMovieCredits()}
|
||||
@@ -226,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>
|
||||
|
||||
@@ -9,10 +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;
|
||||
@@ -20,7 +22,13 @@
|
||||
|
||||
let privateDisabled = false;
|
||||
let privateThoughtsDisabled = false;
|
||||
let exportDisabled = false;
|
||||
let hideSpoilersDisabled = false;
|
||||
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;
|
||||
@@ -88,6 +96,48 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function downloadWatchedList() {
|
||||
const nid = notify({ text: "Exporting", type: "loading" });
|
||||
try {
|
||||
exportDisabled = true;
|
||||
// We re-fetch, to ensure data we export is up to date.
|
||||
const r = await axios.get("/watched");
|
||||
console.log(r.data);
|
||||
if (!r.data || r.data?.length <= 0) {
|
||||
notify({
|
||||
id: nid,
|
||||
text: "Can't export an empty watch list!",
|
||||
type: "error",
|
||||
time: 10000
|
||||
});
|
||||
exportDisabled = false;
|
||||
return;
|
||||
}
|
||||
const file = new Blob([JSON.stringify(r.data, undefined, 2)], {
|
||||
type: "application/json"
|
||||
});
|
||||
const a = document.createElement("a");
|
||||
a.href = URL.createObjectURL(file);
|
||||
a.download = "watcharr-export.json";
|
||||
a.click();
|
||||
exportDisabled = false;
|
||||
notify({ id: nid, text: "Successfully Exported", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("downloadWatchedList failed!", err);
|
||||
notify({ id: nid, text: "Export Failed!", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes in number of minutes and converts to readable.
|
||||
* eg into hours and minutes.
|
||||
*/
|
||||
function toFormattedMinutes(m: number) {
|
||||
const hours = Math.floor(m / 60);
|
||||
const minutes = m % 60;
|
||||
return `${hours ? `${hours}h ` : ""}${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
@@ -104,12 +154,18 @@
|
||||
</div>
|
||||
|
||||
<Stats>
|
||||
{#await getProfile()}
|
||||
{#await getProfilePromise}
|
||||
<Spinner />
|
||||
{:then profile}
|
||||
<Stat name="Joined" value={formatDate(new Date(profile.joined))} />
|
||||
<Stat name="Movies Watched" value={profile.moviesWatched} large />
|
||||
<Stat name="Shows Watched" value={profile.showsWatched} large />
|
||||
<Stat name="Watching Movies" value={toFormattedMinutes(profile.moviesWatchedRuntime)} />
|
||||
<Stat
|
||||
name="Watching Shows"
|
||||
value={toFormattedMinutes(profile.showsWatchedRuntime)}
|
||||
disc="This is very inaccurate 🚀"
|
||||
/>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to get stats!" />
|
||||
{/await}
|
||||
@@ -186,9 +242,60 @@
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting
|
||||
title="Include Previously Watched"
|
||||
desc="Do you want to include previously watched content in the 'finished' filter, even if their status has now been changed?"
|
||||
row
|
||||
>
|
||||
<Checkbox
|
||||
name="includePreviouslyWatched"
|
||||
disabled={includePreviouslyWatchedDisabled}
|
||||
value={settings?.includePreviouslyWatched}
|
||||
toggled={(on) => {
|
||||
includePreviouslyWatchedDisabled = true;
|
||||
updateUserSetting("includePreviouslyWatched", on, () => {
|
||||
includePreviouslyWatchedDisabled = false;
|
||||
// Get profile stats again
|
||||
getProfilePromise = getProfile();
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
<div class="row btns">
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
<button on:click={() => downloadWatchedList()} disabled={exportDisabled}>Export</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
|
||||
userName={user?.username}
|
||||
onClose={() => {
|
||||
pwChangeModalOpen = false;
|
||||
}}
|
||||
></PwChangeModal>
|
||||
{/if}
|
||||
{#if jellyfinSyncModalOpen}
|
||||
<SyncModal onClose={() => (jellyfinSyncModalOpen = false)} />
|
||||
{/if}
|
||||
{#if plexSyncModalOpen}
|
||||
<SyncModal type="plex" onClose={() => (plexSyncModalOpen = false)} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,7 +305,7 @@
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 0 30px;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
min-width: 400px;
|
||||
@@ -267,7 +374,7 @@
|
||||
align-items: center;
|
||||
|
||||
&.btns button {
|
||||
width: min-content;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<script lang="ts">
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import type { ChangePasswordForm } from "@/types";
|
||||
import { changeUserPassword } from "$lib/util/api";
|
||||
|
||||
export let userName: string | undefined;
|
||||
export let onClose: () => void;
|
||||
export let changepswd: ChangePasswordForm = {
|
||||
currentPassword: "",
|
||||
newPassword: "",
|
||||
reEnteredNewPassword: ""
|
||||
};
|
||||
|
||||
let error: string;
|
||||
let errs: string[] = [];
|
||||
|
||||
function checkForm() {
|
||||
errs = [];
|
||||
console.log("Change Password Form - Status: Checking if any form inputs are missing");
|
||||
if (!changepswd.currentPassword) {
|
||||
errs.push("Current Password");
|
||||
}
|
||||
if (!changepswd.newPassword) {
|
||||
errs.push("New Password");
|
||||
}
|
||||
if (!changepswd.reEnteredNewPassword) {
|
||||
errs.push("Re-Entered New Password");
|
||||
}
|
||||
if (errs.length > 0) {
|
||||
error = `Missing required params: ${errs.join(", ")}`;
|
||||
console.log(
|
||||
`Change Password Form - Error: following form inputs are missing:\n${errs.join("\n")}`
|
||||
);
|
||||
} else {
|
||||
console.log("Change Password Form - Status: All form inputs are present");
|
||||
checkFormPasswordsMatch();
|
||||
}
|
||||
}
|
||||
|
||||
function checkFormPasswordsMatch() {
|
||||
console.log(
|
||||
"Change Password Form - Status: Checking if new password and re-entered new password match"
|
||||
);
|
||||
if (changepswd.newPassword !== changepswd.reEnteredNewPassword) {
|
||||
error = "New password and re-entered new password do not match";
|
||||
console.log(
|
||||
"Change Password Form - Error: New password and re-entered new password do not match"
|
||||
);
|
||||
} else {
|
||||
console.log("Change Password Form - Status: New password and re-entered new password match");
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(ev: SubmitEvent) {
|
||||
checkForm();
|
||||
if (!error) {
|
||||
console.log(
|
||||
"Change Password Form - Status: Form inputs on the frontend are valid - sending request to change password to the server"
|
||||
);
|
||||
const fd = new FormData(ev.target! as HTMLFormElement);
|
||||
changepswd.currentPassword = fd.get("current-password") as string;
|
||||
changepswd.newPassword = fd.get("new-password") as string;
|
||||
changeUserPassword(changepswd.currentPassword, changepswd.newPassword, (errMsg) => {
|
||||
if (errMsg) {
|
||||
error = errMsg;
|
||||
return;
|
||||
}
|
||||
console.log("Change Password Form - Status: Password changed successfully");
|
||||
onClose();
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title="Change Password"
|
||||
desc="Use the below form to change your password for account {userName}"
|
||||
{onClose}
|
||||
>
|
||||
{#if error}
|
||||
<span class="error">{error}!</span>
|
||||
{/if}
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div class="form-input-container">
|
||||
<div class="form-input">
|
||||
<!--Hiding username info as it is still useful to password managers-->
|
||||
<!--https://www.chromium.org/developers/design-documents/create-amazing-password-forms/#use-hidden-fields-for-implicit-information-->
|
||||
<label for="username" id="username-label">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
id="username-input"
|
||||
value={userName}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-input">
|
||||
<label for="current-password">Current Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="current-password"
|
||||
placeholder="Current password"
|
||||
autocomplete="current-password"
|
||||
bind:value={changepswd.currentPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-input">
|
||||
<label for="new-password">New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="new-password"
|
||||
placeholder="New password"
|
||||
autocomplete="new-password"
|
||||
bind:value={changepswd.newPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="form-input">
|
||||
<label for="re-entered-new-password">Re-enter New Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="re-entered-new-password"
|
||||
placeholder="Re-enter new password"
|
||||
autocomplete="new-password"
|
||||
bind:value={changepswd.reEnteredNewPassword}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit">Change Password</button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
#username-label,
|
||||
#username-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-input-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 20px;
|
||||
margin: 0 15px;
|
||||
}
|
||||
|
||||
.form-input-container > button {
|
||||
margin-left: auto;
|
||||
width: max-content;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -1,24 +1,92 @@
|
||||
<script lang="ts">
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { searchQuery, watchedList } from "@/store";
|
||||
import { searchQuery, serverFeatures, watchedList } from "@/store";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import axios from "axios";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import { getWatchedDependedProps, getPlayedDependedProps } from "@/lib/util/helpers";
|
||||
import PersonPoster from "@/lib/poster/PersonPoster.svelte";
|
||||
import type { ContentSearch, PublicUser } from "@/types";
|
||||
import type {
|
||||
ContentSearch,
|
||||
ContentSearchMovie,
|
||||
ContentSearchPerson,
|
||||
ContentSearchTv,
|
||||
GameSearch,
|
||||
MediaType,
|
||||
PublicUser
|
||||
} from "@/types";
|
||||
import UsersList from "@/lib/UsersList.svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import GamePoster from "@/lib/poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { notify } from "@/lib/util/notify.js";
|
||||
|
||||
export let data;
|
||||
|
||||
$: searchQ = $searchQuery;
|
||||
$: wList = $watchedList;
|
||||
|
||||
type GameWithMediaType = GameSearch & { media_type: "game" };
|
||||
type CombinedResult =
|
||||
| ContentSearchMovie
|
||||
| ContentSearchTv
|
||||
| ContentSearchPerson
|
||||
| GameWithMediaType;
|
||||
|
||||
async function search(query: string) {
|
||||
return (await axios.get(`/content/${query}`)).data as ContentSearch;
|
||||
const f = get(serverFeatures);
|
||||
if (!f.games) {
|
||||
console.log("Search: Only for movies/tv");
|
||||
return (await axios.get<ContentSearch>(`/content/${query}`)).data.results;
|
||||
}
|
||||
console.log("Search: For movies/tv and games");
|
||||
// To get around promise.all rejecting both promises when one fails,
|
||||
// catch them separately and return empty object so we can still
|
||||
// display the other media types.
|
||||
const r = await Promise.all([
|
||||
axios.get<ContentSearch>(`/content/${query}`).catch((err) => {
|
||||
console.error("Movies/Tv search failed!", err);
|
||||
notify({ text: "Movie/Tv Search Failed!", type: "error" });
|
||||
return { data: { results: [] } };
|
||||
}),
|
||||
axios.get<GameSearch[]>(`/game/search/${query}`).catch((err) => {
|
||||
console.error("Game search failed!", err);
|
||||
notify({ text: "Game Search Failed!", type: "error" });
|
||||
return { data: [] };
|
||||
})
|
||||
]);
|
||||
const games: GameWithMediaType[] = r[1].data.map((g) => ({
|
||||
...g,
|
||||
media_type: "game"
|
||||
}));
|
||||
const d = new Array<CombinedResult>().concat
|
||||
.apply([], [r[0].data.results, games])
|
||||
?.sort((a, b) => {
|
||||
let name = "";
|
||||
if (a.media_type === "game" || a.media_type === "tv" || a.media_type === "person") {
|
||||
name = a.name ?? "";
|
||||
} else if (a.media_type === "movie") {
|
||||
name = a.title ?? "";
|
||||
}
|
||||
|
||||
let name2 = "";
|
||||
if (b.media_type === "game" || b.media_type === "tv" || b.media_type === "person") {
|
||||
name2 = b.name ?? "";
|
||||
} else if (b.media_type === "movie") {
|
||||
name2 = b.title ?? "";
|
||||
}
|
||||
|
||||
if (name < name2) {
|
||||
return 1;
|
||||
}
|
||||
if (name > name2) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
return d;
|
||||
}
|
||||
|
||||
async function searchUsers(query: string) {
|
||||
@@ -53,6 +121,55 @@
|
||||
|
||||
{#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.length > 0}
|
||||
{#each results as w (w.id)}
|
||||
{#if w.media_type === "person"}
|
||||
<PersonPoster id={w.id} name={w.name} path={w.profile_path} />
|
||||
{:else if w.media_type === "game"}
|
||||
<GamePoster
|
||||
media={{
|
||||
id: w.id,
|
||||
coverId: w.cover.image_id,
|
||||
name: w.name,
|
||||
summary: w.summary,
|
||||
firstReleaseDate: w.first_release_date
|
||||
}}
|
||||
{...getPlayedDependedProps(w.id, wList)}
|
||||
/>
|
||||
{:else}
|
||||
<Poster media={w} {...getWatchedDependedProps(w.id, w.media_type, wList)} />
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await}
|
||||
|
||||
<!-- {#await searchGames(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
{#if results?.length > 0}
|
||||
{#each results as w (w.id)}
|
||||
<GamePoster media={w} />
|
||||
{/each}
|
||||
{:else}
|
||||
No Search Results!
|
||||
{/if}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await} -->
|
||||
|
||||
<!-- {#await search(data.slug)}
|
||||
<Spinner />
|
||||
{:then results}
|
||||
<h2>Results</h2>
|
||||
<PosterList>
|
||||
@@ -70,7 +187,7 @@
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load results!" error={err} />
|
||||
{/await}
|
||||
{/await} -->
|
||||
{:else}
|
||||
<h2>No Search Query!</h2>
|
||||
{/if}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import Stats from "@/lib/stats/Stats.svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Stat from "@/lib/stats/Stat.svelte";
|
||||
import TwitchModal from "./modals/TwitchModal.svelte";
|
||||
|
||||
let serverConfig: ServerConfig;
|
||||
let sonarrModalOpen = false;
|
||||
@@ -22,11 +23,13 @@
|
||||
let radarrModalOpen = false;
|
||||
let radarrServerEditing: RadarrSettings;
|
||||
let radarrModalEditing = false;
|
||||
let twitchModalOpen = false;
|
||||
// Disabled vars for disabling inputs until api request completes
|
||||
let signupDisabled = false;
|
||||
let debugDisabled = false;
|
||||
let jfDisabled = false;
|
||||
let tmdbkDisabled = false;
|
||||
let plexHostDisabled = false;
|
||||
|
||||
async function getServerConfig() {
|
||||
serverConfig = (await axios.get(`/server/config`)).data as ServerConfig;
|
||||
@@ -35,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) => {
|
||||
@@ -131,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"
|
||||
@@ -186,6 +215,17 @@
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<Setting title="Twitch">
|
||||
<SettingButton
|
||||
title="Twitch"
|
||||
desc="Twitch application credentials for enabling game support (via IGDB)."
|
||||
icon={Object.keys(serverConfig.TWITCH).length > 0 ? "arrow" : "add"}
|
||||
onClick={() => {
|
||||
twitchModalOpen = true;
|
||||
}}
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
<Setting title="Sonarr">
|
||||
{#if serverConfig.SONARR?.length > 0}
|
||||
{#each serverConfig.SONARR as server}
|
||||
@@ -248,6 +288,19 @@
|
||||
/>
|
||||
</Setting>
|
||||
|
||||
{#if twitchModalOpen}
|
||||
<TwitchModal
|
||||
cfg={serverConfig.TWITCH}
|
||||
onClose={() => {
|
||||
// "temporary" solution to showing added servers
|
||||
// and reloading data to revert modified but not saved changes.
|
||||
getServerConfig();
|
||||
getServerFeatures();
|
||||
twitchModalOpen = false;
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if sonarrModalOpen}
|
||||
<SonarrModal
|
||||
servarr={sonarrServerEditing}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Setting from "@/lib/settings/Setting.svelte";
|
||||
import SettingsList from "@/lib/settings/SettingsList.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import type { TwitchSettings } from "@/types";
|
||||
import axios from "axios";
|
||||
|
||||
export let cfg: TwitchSettings;
|
||||
export let onClose: () => void;
|
||||
|
||||
let error: string;
|
||||
let formDisabled = false;
|
||||
|
||||
function checkForm() {
|
||||
let errs: string[] = [];
|
||||
if (!cfg.clientId) {
|
||||
errs.push("Client ID");
|
||||
}
|
||||
if (!cfg.clientSecret) {
|
||||
errs.push("Client Secret");
|
||||
}
|
||||
if (errs.length > 0) {
|
||||
error = `Missing required params: ${errs.join(", ")}`;
|
||||
} else {
|
||||
error = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
checkForm();
|
||||
// if no error set from checkForm func, continue
|
||||
if (!error) {
|
||||
try {
|
||||
const res = await axios.post(`/game/config`, cfg);
|
||||
if (res.status === 200) {
|
||||
notify({
|
||||
type: "success",
|
||||
text: "Changes saved!"
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Failed to save twitch cfg!", err);
|
||||
error = `Failed to save`;
|
||||
if (err?.response?.data?.error) {
|
||||
error = err.response.data.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
title={"Twitch Config"}
|
||||
desc="Setup your twitch application to enable game support."
|
||||
{onClose}
|
||||
>
|
||||
{#if error}
|
||||
<span class="error">{error}!</span>
|
||||
{/if}
|
||||
|
||||
<SettingsList>
|
||||
<a href="https://watcharr.app/docs/server_config/game-support-igdb" target="_blank">
|
||||
Learn how to configure this options at Watcharr Docs.
|
||||
</a>
|
||||
|
||||
<Setting title="Client ID" desc="Twitch application Client ID.">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="yrbrrakvgbce99fzjsidkfoalsk9eee"
|
||||
bind:value={cfg.clientId}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
</Setting>
|
||||
<Setting title="Client Secret" desc="Twitch application Client Secret.">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="yrbralsodkfishfzpajdkflqoefeee"
|
||||
bind:value={cfg.clientSecret}
|
||||
disabled={formDisabled}
|
||||
/>
|
||||
</Setting>
|
||||
<div class="btns">
|
||||
<button on:click={() => save()}>Save</button>
|
||||
</div>
|
||||
</SettingsList>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
text-decoration: underline;
|
||||
transition: opacity 100ms ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 10px;
|
||||
|
||||
:first-child {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 15px;
|
||||
padding-right: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
</style>
|
||||
@@ -36,7 +36,7 @@
|
||||
let jellyfinUrl: string | undefined;
|
||||
|
||||
$: wListItem = $watchedList.find(
|
||||
(w) => w.content.type === "tv" && w.content.tmdbId === data.tvId
|
||||
(w) => w.content?.type === "tv" && w.content?.tmdbId === data.tvId
|
||||
);
|
||||
|
||||
let showId: number | undefined;
|
||||
@@ -121,7 +121,7 @@
|
||||
<Title
|
||||
title={show.name}
|
||||
homepage={show.homepage}
|
||||
releaseDate={show.first_air_date}
|
||||
releaseYear={new Date(Date.parse(show.first_air_date)).getFullYear()}
|
||||
voteAverage={show.vote_average}
|
||||
voteCount={show.vote_count}
|
||||
/>
|
||||
@@ -179,6 +179,10 @@
|
||||
placeholder={`My thoughts on ${show.name}`}
|
||||
value={wListItem?.thoughts}
|
||||
on:blur={(e) => {
|
||||
if (wListItem?.thoughts === e.currentTarget.value) {
|
||||
// thoughts didn't change
|
||||
return;
|
||||
}
|
||||
contentChanged(undefined, undefined, e.currentTarget?.value);
|
||||
}}
|
||||
/>
|
||||
@@ -186,7 +190,7 @@
|
||||
</div>
|
||||
|
||||
{#if showId}
|
||||
<FollowedThoughts mediaType="tv" tmdbId={showId} />
|
||||
<FollowedThoughts mediaType="tv" mediaId={showId} />
|
||||
{/if}
|
||||
|
||||
{#await getTvCredits()}
|
||||
@@ -223,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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+19
-2
@@ -1,6 +1,5 @@
|
||||
import { writable } from "svelte/store";
|
||||
import type {
|
||||
FileWithData,
|
||||
Filters,
|
||||
Follow,
|
||||
ImportedList,
|
||||
@@ -8,6 +7,7 @@ import type {
|
||||
ServerFeatures,
|
||||
Theme,
|
||||
UserSettings,
|
||||
WLDetailedViewOption,
|
||||
Watched
|
||||
} from "./types";
|
||||
import type { Notification } from "./lib/util/notify";
|
||||
@@ -23,11 +23,14 @@ export const notifications = writable<Notification[]>([]);
|
||||
export const activeSort = writable<string[]>(defaultSort);
|
||||
export const activeFilters = writable<Filters>({ type: [], status: [] });
|
||||
export const appTheme = writable<Theme>();
|
||||
export const importedList = writable<FileWithData | undefined>();
|
||||
export const importedList = writable<
|
||||
{ data: string; type: "text-list" | "tmdb" | "movary" } | undefined
|
||||
>();
|
||||
export const parsedImportedList = writable<ImportedList[] | undefined>();
|
||||
export const searchQuery = writable<string>("");
|
||||
export const serverFeatures = writable<ServerFeatures>();
|
||||
export const follows = writable<Follow[]>();
|
||||
export const wlDetailedView = writable<WLDetailedViewOption[]>([]);
|
||||
|
||||
export const clearAllStores = () => {
|
||||
watchedList.set([]);
|
||||
@@ -40,6 +43,7 @@ export const clearAllStores = () => {
|
||||
userInfo.set(undefined);
|
||||
userSettings.set(undefined);
|
||||
follows.set([]);
|
||||
wlDetailedView.set([]);
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
@@ -68,6 +72,11 @@ if (browser) {
|
||||
toggleTheme(defTheme);
|
||||
}
|
||||
|
||||
const wlDetailedViewR = localStorage.getItem("wlDetailedView");
|
||||
if (wlDetailedViewR) {
|
||||
wlDetailedView.update((v) => (v = JSON.parse(wlDetailedViewR)));
|
||||
}
|
||||
|
||||
// Save changes
|
||||
activeSort.subscribe((v) => {
|
||||
localStorage.setItem("activeFilter", JSON.stringify(v));
|
||||
@@ -80,4 +89,12 @@ if (browser) {
|
||||
appTheme.subscribe((v) => {
|
||||
localStorage.setItem("theme", v);
|
||||
});
|
||||
|
||||
wlDetailedView.subscribe((v) => {
|
||||
if (v) {
|
||||
localStorage.setItem("wlDetailedView", JSON.stringify(v));
|
||||
} else {
|
||||
localStorage.removeItem("wlDetailedView");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+210
-9
@@ -11,6 +11,7 @@ export type Icon =
|
||||
| "play"
|
||||
| "pause"
|
||||
| "jellyfin"
|
||||
| "plex"
|
||||
| "trash"
|
||||
| "close"
|
||||
| "filter"
|
||||
@@ -26,16 +27,26 @@ export type Icon =
|
||||
| "people"
|
||||
| "person"
|
||||
| "person-add"
|
||||
| "person-minus";
|
||||
| "person-minus"
|
||||
| "pencil"
|
||||
| "eye"
|
||||
| "star"
|
||||
| "movary";
|
||||
|
||||
export type Theme = "light" | "dark";
|
||||
|
||||
export type WLDetailedViewOption = "statusRating" | "lastWatched" | "dateAdded" | "dateModified";
|
||||
export type ExtraDetails = { lastWatched: string; dateAdded: string; dateModified: string };
|
||||
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;
|
||||
@@ -67,15 +78,25 @@ export interface WatchedSeason {
|
||||
seasonNumber: number;
|
||||
}
|
||||
|
||||
export interface Watched extends dbModel {
|
||||
export interface WatchedEpisode {
|
||||
id: number;
|
||||
watchedID: number;
|
||||
status: WatchedStatus;
|
||||
rating: number;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
}
|
||||
|
||||
export interface Watched extends dbModel {
|
||||
watched: boolean;
|
||||
rating?: number;
|
||||
content: Content;
|
||||
content?: Content;
|
||||
game?: Game;
|
||||
activity: Activity[];
|
||||
status: WatchedStatus;
|
||||
thoughts: string;
|
||||
watchedSeasons?: WatchedSeason[];
|
||||
watchedEpisodes?: WatchedEpisode[];
|
||||
}
|
||||
|
||||
export interface WatchedAddRequest {
|
||||
@@ -85,6 +106,12 @@ export interface WatchedAddRequest {
|
||||
status: WatchedStatus;
|
||||
}
|
||||
|
||||
export interface PlayedAddRequest {
|
||||
rating?: number;
|
||||
status?: WatchedStatus;
|
||||
igdbId?: number;
|
||||
}
|
||||
|
||||
export interface WatchedUpdateRequest {
|
||||
rating?: number;
|
||||
status?: WatchedStatus;
|
||||
@@ -96,21 +123,39 @@ export interface WatchedUpdateResponse {
|
||||
newActivity: Activity;
|
||||
}
|
||||
|
||||
export interface ActivityUpdateRequest {
|
||||
customDate: string;
|
||||
}
|
||||
|
||||
export interface WatchedSeasonAddResponse {
|
||||
watchedSeasons: WatchedSeason[];
|
||||
addedActivity: Activity;
|
||||
}
|
||||
|
||||
export interface WatchedEpisodeAddResponse {
|
||||
watchedEpisodes: WatchedEpisode[];
|
||||
addedActivity: Activity;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
joined: Date;
|
||||
showsWatched: number;
|
||||
moviesWatched: number;
|
||||
moviesWatchedRuntime: number;
|
||||
showsWatchedRuntime: number;
|
||||
}
|
||||
|
||||
export interface UserSettings {
|
||||
private: boolean;
|
||||
privateThoughts: boolean;
|
||||
hideSpoilers: boolean;
|
||||
includePreviouslyWatched: boolean;
|
||||
}
|
||||
|
||||
export interface ChangePasswordForm {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
reEnteredNewPassword: string;
|
||||
}
|
||||
|
||||
// What the user search returns
|
||||
@@ -151,6 +196,7 @@ export interface AvailableAuthProviders {
|
||||
available: string[];
|
||||
signupEnabled: boolean;
|
||||
isInSetup: boolean;
|
||||
plexOauthId: string;
|
||||
}
|
||||
|
||||
export interface TokenClaims {
|
||||
@@ -159,11 +205,6 @@ export interface TokenClaims {
|
||||
type: number;
|
||||
}
|
||||
|
||||
export interface FileWithData {
|
||||
file: File;
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface TMDBContentDetails {
|
||||
id: number;
|
||||
backdrop_path: string;
|
||||
@@ -669,6 +710,18 @@ export interface ContentSearchPerson {
|
||||
popularity?: number;
|
||||
}
|
||||
|
||||
export interface GameSearch {
|
||||
id: number;
|
||||
cover: {
|
||||
id: number;
|
||||
image_id: string;
|
||||
};
|
||||
first_release_date: string;
|
||||
name: string;
|
||||
summary?: string;
|
||||
version_title?: string;
|
||||
}
|
||||
|
||||
export enum ImportResponseType {
|
||||
IMPORT_SUCCESS = "IMPORT_SUCCESS",
|
||||
IMPORT_FAILED = "IMPORT_FAILED",
|
||||
@@ -692,6 +745,9 @@ export interface ImportedList {
|
||||
state?: string;
|
||||
rating?: number;
|
||||
ratingCustomDate?: Date;
|
||||
status?: WatchedStatus;
|
||||
thoughts?: string;
|
||||
datesWatched?: Date[];
|
||||
}
|
||||
|
||||
export interface Filters {
|
||||
@@ -703,8 +759,11 @@ 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;
|
||||
DEBUG: boolean;
|
||||
}
|
||||
|
||||
@@ -727,6 +786,11 @@ export interface RadarrSettings {
|
||||
automaticSearch?: boolean;
|
||||
}
|
||||
|
||||
export interface TwitchSettings {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export interface DropDownItem {
|
||||
id: number;
|
||||
value: string;
|
||||
@@ -796,9 +860,146 @@ export interface RadarrTestResponse {
|
||||
export interface ServerFeatures {
|
||||
sonarr: boolean;
|
||||
radarr: boolean;
|
||||
games: boolean;
|
||||
}
|
||||
|
||||
export interface Follow {
|
||||
createdAt: Date;
|
||||
followedUser: PublicUser;
|
||||
}
|
||||
|
||||
interface MovaryExportBase {
|
||||
title: string;
|
||||
year: string;
|
||||
tmdbId: string;
|
||||
imdbId: string;
|
||||
}
|
||||
|
||||
export interface MovaryHistory extends MovaryExportBase {
|
||||
watchedAt: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface MovaryRatings extends MovaryExportBase {
|
||||
userRating: string;
|
||||
}
|
||||
|
||||
export interface MovaryWatchlist extends MovaryExportBase {
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
updatedAt: string;
|
||||
igdbId: number;
|
||||
name: string;
|
||||
coverId: string;
|
||||
summary: string;
|
||||
storyline: string;
|
||||
releaseDate?: string;
|
||||
rating: number;
|
||||
ratingCount: number;
|
||||
status: number;
|
||||
category: number;
|
||||
poster?: Image;
|
||||
}
|
||||
|
||||
export interface GameDetailsResponse {
|
||||
id: number;
|
||||
artworks: {
|
||||
width: number;
|
||||
height: number;
|
||||
image_id: string;
|
||||
}[];
|
||||
category: number;
|
||||
cover: {
|
||||
id: number;
|
||||
image_id: string;
|
||||
};
|
||||
first_release_date: number;
|
||||
game_modes: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
genres: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
involved_companies: {
|
||||
id: number;
|
||||
company: {
|
||||
id: number;
|
||||
description: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
websites: {
|
||||
id: number;
|
||||
category: number;
|
||||
trusted: boolean;
|
||||
url: string;
|
||||
}[];
|
||||
};
|
||||
developer: boolean;
|
||||
porting: boolean;
|
||||
publisher: boolean;
|
||||
supporting: boolean;
|
||||
}[];
|
||||
name: string;
|
||||
platforms: {
|
||||
id: number;
|
||||
name: string;
|
||||
}[];
|
||||
rating: number;
|
||||
rating_count: number;
|
||||
summary: string;
|
||||
storyline: string;
|
||||
url: string;
|
||||
videos: {
|
||||
id: number;
|
||||
name: string;
|
||||
video_id: string;
|
||||
}[];
|
||||
websites: {
|
||||
id: number;
|
||||
category: number;
|
||||
trusted: boolean;
|
||||
url: string;
|
||||
}[];
|
||||
similar_games: {
|
||||
id: number;
|
||||
name: string;
|
||||
summary: string;
|
||||
first_release_date: number;
|
||||
cover: {
|
||||
id: number;
|
||||
image_id: string;
|
||||
};
|
||||
}[];
|
||||
}
|
||||
|
||||
export enum GameWebsiteCategory {
|
||||
Official = 1,
|
||||
Wikipedia = 3,
|
||||
Twitch = 6,
|
||||
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[];
|
||||
}
|
||||
|
||||
@@ -35,8 +35,10 @@ $backdrop-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 80%, rgba(0, 0
|
||||
$nav-color: var(--nav-color);
|
||||
$nav-height: 71px; // How tall the nav is naturally, usefull in some places.
|
||||
$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);
|
||||
|
||||
Reference in New Issue
Block a user