Compare commits
319 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3abc549b1 | |||
| 3dfb88bfb4 | |||
| 731ebce307 | |||
| 1eacde4555 | |||
| d48e7286d8 | |||
| 27f081a8c6 | |||
| 17d1a33867 | |||
| 8acc07cb94 | |||
| 7335c83a08 | |||
| 695aa5bb0b | |||
| f7685f22c6 | |||
| 952ef27781 | |||
| e8f186fd54 | |||
| d0a964775e | |||
| 866432d22e | |||
| 5dce12c73b | |||
| 6d002b4d1a | |||
| 5a66b51b00 | |||
| 16d53be62c | |||
| afe1947bd3 | |||
| faff9ff366 | |||
| e7b0d4f3d1 | |||
| a8650a47a5 | |||
| 1a0319f316 | |||
| fc943d2eea | |||
| edc292be19 | |||
| 01d78695a8 | |||
| be14a4689c | |||
| aea4aab78a | |||
| 0619338a97 | |||
| 41677ea717 | |||
| 6304c33436 | |||
| 41af9c9fe1 | |||
| bade703b4d | |||
| 7220684a97 | |||
| da7f62af12 | |||
| 9054729069 | |||
| 444105955e | |||
| a23bfc5fac | |||
| 0934f57fdb | |||
| 4669994b4a | |||
| 1135e1132a | |||
| a22c830357 | |||
| f09005d171 | |||
| 459c729da1 | |||
| aacdfebdcf | |||
| 41ad80c1a9 | |||
| a472b18981 | |||
| 7d92901f80 | |||
| 0a83567c11 | |||
| 007a87b842 | |||
| cbac14f77e | |||
| a6fcd93244 | |||
| 2801c5540f | |||
| 20b3a2631a | |||
| 3a7c6c07d5 | |||
| 6b8d686dae | |||
| 83d869417b | |||
| 9bf216537c | |||
| 611b2bbd43 | |||
| 2b35e0b270 | |||
| 7e57e50b99 | |||
| 0703a511c0 | |||
| fba76e38fb | |||
| 7960876873 | |||
| 2395908c20 | |||
| a3e8877199 | |||
| bca9336d4f | |||
| 4565af412b | |||
| 232d704d9f | |||
| 869b13f473 | |||
| b28e06f559 | |||
| 3af9177849 | |||
| f83ec47bc3 | |||
| 4cda259a2a | |||
| 4a6932f5b1 | |||
| 0b508772b8 | |||
| e5dfe96186 | |||
| 1a20e1cdd9 | |||
| 6f47c291cb | |||
| 79a64fd667 | |||
| dba92dc3e0 | |||
| f79393d072 | |||
| 350c6db596 | |||
| b9c0a9f700 | |||
| 03572c1324 | |||
| 1eef015e5e | |||
| 00d284bb07 | |||
| 56b44bd9ab | |||
| 170c96ac8a | |||
| 416ce62081 | |||
| 06bfae19ed | |||
| c3b0411bb5 | |||
| ba3b8d9281 | |||
| 1378193031 | |||
| 59a705222f | |||
| 1e50a4fa5b | |||
| 2b36272c2d | |||
| e1ead91d2c | |||
| 80e7062151 | |||
| 57d124c31e | |||
| 9ebfc2d18a | |||
| a07d9e2882 | |||
| e605c8e71a | |||
| 1387aa3a82 | |||
| e29f782616 | |||
| da3b932be2 | |||
| 0a649acc0f | |||
| 4db88d040c | |||
| cf6136ce92 | |||
| 1acdd245a2 | |||
| 1c31dd31ac | |||
| e9b75abe16 | |||
| b098a25c6a | |||
| 9081d3d752 | |||
| 383e0bf2f4 | |||
| a600affaac | |||
| c436219b62 | |||
| fd726c4cbe | |||
| 828f6f3aa3 | |||
| 5b14857c8d | |||
| 82acd83395 | |||
| 93ebc0380f | |||
| 5f4efac9ee | |||
| efe697e1e9 | |||
| b90458ebb9 | |||
| bade3f54cb | |||
| 975fddfc6b | |||
| 31c23ac860 | |||
| 7519098d47 | |||
| 4745d528f2 | |||
| a4d0acb5dd | |||
| 10bd2e4bd3 | |||
| a0f6991746 | |||
| 61cdf91382 | |||
| 7f15f796f1 | |||
| 31ace217be | |||
| c502950473 | |||
| 60e0652123 | |||
| 53f2ae1bdb | |||
| 118c6dfba4 | |||
| 4d789d46c6 | |||
| f461f56299 | |||
| b16e18fe13 | |||
| 9055bdd58a | |||
| e1e900d7f8 | |||
| 617be082ac | |||
| 6738963141 | |||
| 64c1f7fc11 | |||
| 147561f8c7 | |||
| 8a5cc8bdef | |||
| 6fafdf5dc2 | |||
| a9e871a393 | |||
| b10c17d485 | |||
| ccfd5a8fcb | |||
| f6ef3f9fa1 | |||
| 690ae67f2a | |||
| d6666a8072 | |||
| 9d8140a137 | |||
| a701528f7f | |||
| 87fec68591 | |||
| d425dda240 | |||
| 12f33bfb3f | |||
| cc1df09cb8 | |||
| 5f1ef5145a | |||
| 405c8169cf | |||
| 9b2259a9a3 | |||
| 9b8fa436b8 | |||
| c28a1e3793 | |||
| 90dfd39f91 | |||
| 78d14c49b4 | |||
| 2923337258 | |||
| 7ad6621fbb | |||
| bfab3b2802 | |||
| 9283b818f1 | |||
| cc00896fa4 | |||
| a61f305f03 | |||
| c4b6e41016 | |||
| 0e4223787f | |||
| d9abc061ed | |||
| 8f4ea45428 | |||
| 6366377e9e | |||
| 669bfa026d | |||
| 15c6d902f2 | |||
| 80f119ca81 | |||
| c287e2fb76 | |||
| 4c597508b3 | |||
| 42fff9c3cb | |||
| c0d6bc0f4d | |||
| 98a5818498 | |||
| d228da76c1 | |||
| 75073e48ad | |||
| 3702fa5e36 | |||
| 7bb56331ee | |||
| b953a41485 | |||
| a3ead1ca7e | |||
| aeb49bd587 | |||
| c4ac52267b | |||
| b61d8459e0 | |||
| a12c23a866 | |||
| ebb43b67c3 | |||
| 080a1f503e | |||
| 0b46205f52 | |||
| 79df259714 | |||
| 7b70889118 | |||
| f5135e0825 | |||
| 3fa9dbd109 | |||
| b2438c735b | |||
| f35c42bce5 | |||
| fd68d7d975 | |||
| 00e45aa6d0 | |||
| ce9c92010f | |||
| 0caa18e587 | |||
| db43c313b8 | |||
| 328f035d1e | |||
| f1db1ff2b4 | |||
| 2c6fa24d65 | |||
| f3b4b7acc4 | |||
| bed4a922c5 | |||
| fc64a66f47 | |||
| 083381d4d5 | |||
| b4f3bf906b | |||
| 12736f91c9 | |||
| e1e0ad6db6 | |||
| f403e228f6 | |||
| 242656e2f4 | |||
| 62ffee4d84 | |||
| b83d567306 | |||
| 9d67db2466 | |||
| fb8dec81f1 | |||
| 115ee57687 | |||
| 13c4aff1d3 | |||
| 320a88f1db | |||
| f175b74320 | |||
| 941e8879fd | |||
| a739862ff1 | |||
| c240de748b | |||
| 80d343e12f | |||
| 3247860cce | |||
| 67c9edb63d | |||
| e025d5fbab | |||
| fdfda036d4 | |||
| 329fc33c8a | |||
| 9b8b59df5e | |||
| 5d0bbd9825 | |||
| 4a910c9c57 | |||
| b7ce96c576 | |||
| 34b756d039 | |||
| 3aec67865a | |||
| a051a07b1a | |||
| 2da1af2f12 | |||
| bf0b93f90f | |||
| 76ebdbfe8b | |||
| cc94be7d9b | |||
| 11e575faf1 | |||
| 658c4ba114 | |||
| a62a05790f | |||
| 2dbbd08ea3 | |||
| 8d4297211a | |||
| 7d8bc81aa9 | |||
| 780f922c22 | |||
| e1420ed4c4 | |||
| 89849a0957 | |||
| 19547861ab | |||
| 05739dc085 | |||
| d17f24fab9 | |||
| 075eb01533 | |||
| 07f236385f | |||
| 1359279e8c | |||
| 577c90402b | |||
| 9c9436b420 | |||
| c9a7ec5f66 | |||
| 54c0aca002 | |||
| 589bd3f86d | |||
| ffbad648d8 | |||
| c187c7e09d | |||
| e57b45e0b8 | |||
| 94c12ac428 | |||
| a65604e315 | |||
| 73f6bd2dd5 | |||
| 993bdeb5f1 | |||
| c06609cc2e | |||
| fe2751d9a4 | |||
| 873833a381 | |||
| 98f5dc5394 | |||
| 82ec4aa0e7 | |||
| 7ab1d4b7a1 | |||
| 0f29aeb1d0 | |||
| c295cc490a | |||
| bdf9b1d3d0 | |||
| db6304e5f9 | |||
| a61f0dbbf3 | |||
| 84cd421368 | |||
| 9c32a73311 | |||
| cf90b8537b | |||
| 43da5706ed | |||
| 2e301c33a4 | |||
| 9dddac9820 | |||
| e9a17a0f4a | |||
| 4482da9ec7 | |||
| 0753346954 | |||
| 4878e8d32b | |||
| 86aef4fdd2 | |||
| 35dcaf11a1 | |||
| 2a69951d2e | |||
| 67d17c5a82 | |||
| bbd5533ac8 | |||
| e234524532 | |||
| c4ec47ec97 | |||
| 86b0f1a917 | |||
| c929911187 | |||
| 736a06d362 | |||
| 755c4ca15f | |||
| a0fbb0ffef | |||
| e356ad604e | |||
| 1c6df50622 | |||
| 88796292be | |||
| b97ea7076d | |||
| ee2e573083 |
@@ -7,6 +7,8 @@ node_modules
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
/doc
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['svelte3', '@typescript-eslint'],
|
||||
ignorePatterns: ['*.cjs'],
|
||||
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
|
||||
settings: {
|
||||
'svelte3/typescript': () => require('typescript')
|
||||
},
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false
|
||||
}
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: ["plugin:svelte/recommended"],
|
||||
plugins: ["@typescript-eslint"],
|
||||
ignorePatterns: ["*.cjs", "*.config.js"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.svelte"],
|
||||
parser: "svelte-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser"
|
||||
}
|
||||
}
|
||||
],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
extraFileExtensions: [".svelte"],
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Web UI
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ui"
|
||||
|
||||
# Server
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "server"
|
||||
|
||||
# Workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # / will look in /.github/workflows directory by default
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "workflow"
|
||||
@@ -16,17 +16,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -19,10 +19,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -30,12 +30,12 @@ jobs:
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.image }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Publish Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- ".github/workflows/publish-docs.yml"
|
||||
- "doc/**"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: doc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Build website
|
||||
run: npm run build
|
||||
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
publish_dir: ./doc/build
|
||||
cname: watcharr.app
|
||||
# The following lines assign commit authorship to the official
|
||||
# GH-Actions bot for deploys to `gh-pages` branch:
|
||||
# https://github.com/actions/checkout/issues/13#issuecomment-724415212
|
||||
# The GH actions bot is used by default if you didn't specify the two fields.
|
||||
# You can swap them out with your own user credentials.
|
||||
user_name: github-actions[bot]
|
||||
user_email: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Test doc deployment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- ".github/workflows/test-docs.yml"
|
||||
- "doc/**"
|
||||
|
||||
jobs:
|
||||
test-deploy:
|
||||
name: Test deployment
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: doc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Test build website
|
||||
run: npm run build
|
||||
@@ -1,23 +1,18 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "always",
|
||||
"plugins": [
|
||||
"prettier-plugin-svelte"
|
||||
],
|
||||
"pluginSearchDirs": [
|
||||
"."
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch Package",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"program": "${fileDirname}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
# Contributing
|
||||
# Contributing 🎉
|
||||
|
||||
Feel free to contribute in any way: bug reports, ideas, etc - everything is welcome!
|
||||
First of all, thank you for looking into contributing! 🫡
|
||||
|
||||
Feel free to contribute in any way: 🐞 bug reports, 💡 ideas, 🥖 new features etc - everything is welcome!
|
||||
|
||||
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
|
||||
|
||||
## Project Board
|
||||
|
||||
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Backend
|
||||
FROM golang:1.20 AS server
|
||||
FROM golang:1.21 AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sbondCo/Watcharr/pkgs/container/watcharr"><img src="https://img.shields.io/github/v/release/sbondCo/Watcharr?label=version&style=for-the-badge" /></a>
|
||||
<a href="https://watcharr.lab.sbond.co"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fwatcharr.lab.sbond.co%2F" /></a>
|
||||
<a href="https://beta.watcharr.app"><img src="https://img.shields.io/website?label=DEMO&style=for-the-badge&url=https%3A%2F%2Fbeta.watcharr.app" /></a>
|
||||
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
|
||||
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
|
||||
</p>
|
||||
@@ -12,7 +12,7 @@ I'm your new easily self-hosted content watched list. The place you store your w
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://watcharr.lab.sbond.co/](https://watcharr.lab.sbond.co/)
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
|
||||
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
|
||||
|
||||
@@ -20,6 +20,8 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
|
||||
- [Screenshots](#screenshots)
|
||||
- [Setup](#set-up)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Getting Help](#getting-help)
|
||||
|
||||
# Screenshots
|
||||
|
||||
@@ -37,6 +39,8 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
|
||||
Currently only a [docker image](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr) is provided (**v1.9.1 and newer no longer require the `watcharr-ui` package, it is bundled inside the main `watcharr` package**).
|
||||
|
||||
**Note:** When first running, make sure only you have access. The first user created will become admin.
|
||||
|
||||
Here is a simple setup you can use yourself, using docker compose:
|
||||
|
||||
**docker-compose.yml**
|
||||
@@ -53,24 +57,14 @@ services:
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
# .env file used to configure watcharr
|
||||
- .env:/.env
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
**.env**
|
||||
After loading up Watcharr, you will be greeted with a menu to setup the first account (which will be admin).
|
||||
|
||||
```
|
||||
# Used to sign JWT tokens. Make sure to make
|
||||
# it strong, just like a very long, complicated password.
|
||||
JWT_SECRET=MAKE_ME_RANDOM_AND_LONG
|
||||
When logged in you can then configure the server settings from the nav face menu > settings option.
|
||||
|
||||
# Optional: Point to your Jellyfin install
|
||||
# to enable it as an auth provider.
|
||||
JELLYFIN_HOST=https://my.jellyfin.example
|
||||
# Getting Help
|
||||
|
||||
# Optional: When not set we assume production, should only
|
||||
# be set to DEV when developing the app.
|
||||
MODE=prod
|
||||
```
|
||||
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
|
||||
# Production
|
||||
/build
|
||||
|
||||
# Generated files
|
||||
.docusaurus
|
||||
.cache-loader
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,41 @@
|
||||
# Website
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
### Installation
|
||||
|
||||
```
|
||||
$ yarn
|
||||
```
|
||||
|
||||
### Local Development
|
||||
|
||||
```
|
||||
$ yarn start
|
||||
```
|
||||
|
||||
This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server.
|
||||
|
||||
### Build
|
||||
|
||||
```
|
||||
$ yarn build
|
||||
```
|
||||
|
||||
This command generates static content into the `build` directory and can be served using any static contents hosting service.
|
||||
|
||||
### Deployment
|
||||
|
||||
Using SSH:
|
||||
|
||||
```
|
||||
$ USE_SSH=true yarn deploy
|
||||
```
|
||||
|
||||
Not using SSH:
|
||||
|
||||
```
|
||||
$ GIT_USER=<Your GitHub username> yarn deploy
|
||||
```
|
||||
|
||||
If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch.
|
||||
@@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Choose how you would like to install Watcharr."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Docker Compose
|
||||
|
||||
## Installing
|
||||
|
||||
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
# to use an actual version, then when updating check the releases for changelogs.
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
# Contains all of watcharr data (database & cache)
|
||||
- ./data:/data
|
||||
```
|
||||
|
||||
:::danger first account
|
||||
|
||||
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
|
||||
|
||||
:::
|
||||
|
||||
You can now start `Watcharr` like so:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
If you didn't change the ports in the example, the server will be available at [http://localhost:3080/](http://localhost:3080/).
|
||||
|
||||
## Updating
|
||||
|
||||
:::danger Take care
|
||||
|
||||
We try taking care as to not release breaking changes, however it is still recommended that
|
||||
you lookover changelogs before updating!
|
||||
|
||||
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
|
||||
|
||||
:::
|
||||
|
||||
Updating your server can be done in two steps:
|
||||
|
||||
1. Update the `image` version in your `docker-compose.yml` file.
|
||||
Skip this step if you are using the `latest` tag.
|
||||
|
||||
```yaml
|
||||
# eg. update v1.19.0 to v1.20.0 (or whatever version you are updating to)
|
||||
image: ghcr.io/sbondco/watcharr:v1.19.0
|
||||
```
|
||||
|
||||
2. Pull the new changes and re-create your container:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
And that is it!
|
||||
@@ -0,0 +1,74 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
# From Source
|
||||
|
||||
## Installing
|
||||
|
||||
1. Get the code
|
||||
|
||||
```bash
|
||||
git clone https://github.com/sbondCo/Watcharr.git && \
|
||||
cd Watcharr
|
||||
```
|
||||
|
||||
If you want a specific version, checkout a version tag (replace `<tag>` with the tag):
|
||||
|
||||
```bash
|
||||
git checkout <tag>
|
||||
```
|
||||
|
||||
2. Build the frontend
|
||||
|
||||
The server will use the built files, so we move them to the `ui` folder, besides where it's binary will be built.
|
||||
|
||||
```bash
|
||||
npm i && \
|
||||
npm run build && \
|
||||
mv ./build ./server/ui
|
||||
```
|
||||
|
||||
3. Build the server
|
||||
|
||||
```bash
|
||||
cd server && \
|
||||
go mod download && \
|
||||
GOOS=linux go build -o ./watcharr
|
||||
```
|
||||
|
||||
4. Run the server
|
||||
|
||||
:::danger first account
|
||||
|
||||
When **first** running Watcharr, make sure only you have access. The first user created will become admin.
|
||||
|
||||
:::
|
||||
|
||||
```bash
|
||||
./watcharr
|
||||
```
|
||||
|
||||
5. Visit [http://localhost:3080/](http://localhost:3080/) and setup Watcharr.
|
||||
|
||||
## Updating
|
||||
|
||||
:::danger Take care
|
||||
|
||||
We try taking care as to not release breaking changes, however it is still recommended that
|
||||
you lookover changelogs before updating!
|
||||
|
||||
Breaking changes are marked at the top of releases: https://github.com/sbondCo/Watcharr/releases
|
||||
|
||||
:::
|
||||
|
||||
Updating is the same as installing except:
|
||||
|
||||
1. Cleanup old build. Delete the `ui` folder and the `watcharr` binary.
|
||||
2. Replace step one with getting the new code:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
If you checked out a tag, update to the new tag as well.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Introduction
|
||||
|
||||
Watcharr is for the peeps who like to keep a list of all the content they have ever watched.
|
||||
|
||||
If you would like more insight, the [adding to your watchlist guide](/docs/usage/adding-to-watchlist)
|
||||
is a great example of how Watcharr is used.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Usage",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Hopefully Watcharr is easy enough to get started with and use, but incase you are stuck, one of these usage guides may help."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Adding To Your Watchlist
|
||||
|
||||
Adding movies or shows to your watchlist is the core feature of Watcharr.
|
||||
|
||||
The homepage is where your list will appear once you have added some.
|
||||
|
||||
To get started, you can follow along with these steps to add your first movie/show:
|
||||
|
||||
1. Search for the content:
|
||||
|
||||

|
||||
|
||||
2. Hover or click on the content you want to add:
|
||||
|
||||

|
||||
|
||||
3. Clicking the + (Plus) icon will let you give the content a status.
|
||||
|
||||
Currently supported statuses include: `Finished`, `Watching`, `Held`, `Planned`, `Dropped`.
|
||||
|
||||

|
||||
|
||||
4. Rate the content.
|
||||
|
||||

|
||||
|
||||
If you prefer, you can also do this by clicking/tapping on the active poster again.
|
||||
This will take you to the contents full page. Here you can see all the options, including
|
||||
adding `Thoughts` for the content.
|
||||
|
||||

|
||||
|
||||
`Thoughts` can be anything you would like to write down or remember about the specific movie/show.
|
||||
For example, sometimes I like to write something specific that I _really_ liked about the content,
|
||||
or for some shows, I write down my favourite seasons and how many times I have watched them.
|
||||
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 321 KiB |
|
After Width: | Height: | Size: 369 KiB |
@@ -0,0 +1,101 @@
|
||||
// @ts-check
|
||||
// Note: type annotations allow type checking and IDEs autocompletion
|
||||
|
||||
const lightCodeTheme = require("prism-react-renderer/themes/github");
|
||||
const darkCodeTheme = require("prism-react-renderer/themes/dracula");
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: "Watcharr Docs",
|
||||
tagline:
|
||||
"Open source, self-hostable watched list for all your content with user authentication, modern and clean UI and a very simple setup. ",
|
||||
favicon: "img/favicon.png",
|
||||
|
||||
// Set the production url of your site here
|
||||
url: "https://watcharr.app",
|
||||
// Set the /<baseUrl>/ pathname under which your site is served
|
||||
// For GitHub pages deployment, it is often '/<projectName>/'
|
||||
baseUrl: "/",
|
||||
|
||||
// GitHub pages deployment config.
|
||||
// If you aren't using GitHub pages, you don't need these.
|
||||
organizationName: "sbondCo", // Usually your GitHub org/user name.
|
||||
projectName: "Watcharr", // Usually your repo name.
|
||||
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
trailingSlash: false,
|
||||
|
||||
// Even if you don't use internalization, you can use this field to set useful
|
||||
// metadata like html lang. For example, if your site is Chinese, you may want
|
||||
// to replace "en" with "zh-Hans".
|
||||
i18n: {
|
||||
defaultLocale: "en",
|
||||
locales: ["en"]
|
||||
},
|
||||
|
||||
presets: [
|
||||
[
|
||||
"classic",
|
||||
/** @type {import('@docusaurus/preset-classic').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve("./sidebars.js"),
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
// Please change this to your repo.
|
||||
// Remove this to remove the "edit this page" links.
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve("./src/css/custom.css")
|
||||
}
|
||||
})
|
||||
]
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('@docusaurus/preset-classic').ThemeConfig} */
|
||||
({
|
||||
image: "img/social-card.png",
|
||||
navbar: {
|
||||
title: "Watcharr",
|
||||
logo: {
|
||||
alt: "Watcharr Logo",
|
||||
src: "img/favicon.png"
|
||||
},
|
||||
items: [
|
||||
{
|
||||
type: "docSidebar",
|
||||
sidebarId: "tutorialSidebar",
|
||||
position: "left",
|
||||
label: "Docs"
|
||||
},
|
||||
{
|
||||
type: "docsVersionDropdown"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right"
|
||||
}
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme
|
||||
// darkTheme: darkCodeTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "watcharr-doc",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"start": "docusaurus start",
|
||||
"build": "docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
"serve": "docusaurus serve",
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids"
|
||||
},
|
||||
"dependencies": {
|
||||
"@docusaurus/core": "2.4.3",
|
||||
"@docusaurus/preset-classic": "2.4.3",
|
||||
"@mdx-js/react": "^1.6.22",
|
||||
"clsx": "^1.2.1",
|
||||
"prism-react-renderer": "^1.3.5",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "2.4.3"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.5%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Creating a sidebar enables you to:
|
||||
- create an ordered group of docs
|
||||
- render a sidebar for each doc of that group
|
||||
- provide next/previous navigation
|
||||
|
||||
The sidebars can be generated from the filesystem, or explicitly defined here.
|
||||
|
||||
Create as many sidebars as you want.
|
||||
*/
|
||||
|
||||
// @ts-check
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{type: 'autogenerated', dirName: '.'}],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Tutorial',
|
||||
items: ['tutorial-basics/create-a-document'],
|
||||
},
|
||||
],
|
||||
*/
|
||||
};
|
||||
|
||||
module.exports = sidebars;
|
||||
@@ -0,0 +1,61 @@
|
||||
import React from "react";
|
||||
import clsx from "clsx";
|
||||
import styles from "./styles.module.css";
|
||||
|
||||
const FeatureList = [
|
||||
{
|
||||
title: "Free and Open",
|
||||
Svg: require("@site/static/img/icon/code-slash.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is free and open source software distributed under the MIT license. Feel free to
|
||||
browse, modify or contribute!
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Simple",
|
||||
Svg: require("@site/static/img/icon/happy.svg").default,
|
||||
description: (
|
||||
<>
|
||||
Watcharr is incredibly easy to dive into. Install without hassle and get started with the
|
||||
seamless and intuitive user experience.
|
||||
</>
|
||||
)
|
||||
},
|
||||
{
|
||||
title: "Built With Go and Svelte",
|
||||
Svg: require("@site/static/img/icon/go.svg").default,
|
||||
description: (
|
||||
<>Don't know what else to add here, so now you know Watcharr is built with Go and Svelte.</>
|
||||
)
|
||||
}
|
||||
];
|
||||
|
||||
function Feature({ Svg, title, description }) {
|
||||
return (
|
||||
<div className={clsx("col col--4")}>
|
||||
<div className="text--center">
|
||||
<Svg className={styles.featureSvg} role="img" />
|
||||
</div>
|
||||
<div className="text--center padding-horiz--md">
|
||||
<h3>{title}</h3>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomepageFeatures() {
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Any CSS included here will be global. The classic template
|
||||
* bundles Infima by default. Infima is a CSS framework designed to
|
||||
* work well for content-centric websites.
|
||||
*/
|
||||
|
||||
/* You can override the default Infima variables here. */
|
||||
:root {
|
||||
--ifm-color-primary: #2e8555;
|
||||
--ifm-color-primary-dark: #29784c;
|
||||
--ifm-color-primary-darker: #277148;
|
||||
--ifm-color-primary-darkest: #205d3b;
|
||||
--ifm-color-primary-light: #33925d;
|
||||
--ifm-color-primary-lighter: #359962;
|
||||
--ifm-color-primary-lightest: #3cad6e;
|
||||
--ifm-code-font-size: 95%;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* For readability concerns, you should choose a lighter palette in dark mode. */
|
||||
[data-theme='dark'] {
|
||||
--ifm-color-primary: #25c2a0;
|
||||
--ifm-color-primary-dark: #21af90;
|
||||
--ifm-color-primary-darker: #1fa588;
|
||||
--ifm-color-primary-darkest: #1a8870;
|
||||
--ifm-color-primary-light: #29d5b0;
|
||||
--ifm-color-primary-lighter: #32d8b4;
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import Link from "@docusaurus/Link";
|
||||
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
|
||||
import Layout from "@theme/Layout";
|
||||
import HomepageFeatures from "@site/src/components/HomepageFeatures";
|
||||
import LogoCol from "@site/static/img/logo-col.png";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
import clsx from "clsx";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<header className="hero shadow--lw">
|
||||
<div className={styles.heroContainer}>
|
||||
<div className={styles.heroWrapper}>
|
||||
<img src={LogoCol} />
|
||||
<h1 className="hero__title">{siteConfig.title}</h1>
|
||||
<p className="hero__subtitle">{siteConfig.tagline}</p>
|
||||
<div className={styles.buttons}>
|
||||
<Link className="button button--secondary button--lg" to="/docs/introduction">
|
||||
Get Started
|
||||
</Link>
|
||||
<Link
|
||||
className="button button--secondary button--outline button--lg"
|
||||
to="https://beta.watcharr.app"
|
||||
target="_blank"
|
||||
>
|
||||
Demo
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* CSS files with the .module.css suffix will be treated as CSS modules
|
||||
* and scoped locally.
|
||||
*/
|
||||
|
||||
.heroContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.heroWrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 600px;
|
||||
padding-left: 2rem;
|
||||
padding-right: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.heroWrapper img {
|
||||
width: 30vh;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
title: Markdown page example
|
||||
---
|
||||
|
||||
# Markdown page example
|
||||
|
||||
You don't need React to write simple standalone pages.
|
||||
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M160 389a20.91 20.91 0 01-13.82-5.2l-128-112a21 21 0 010-31.6l128-112a21 21 0 0127.66 31.61L63.89 256l109.94 96.19A21 21 0 01160 389zM352 389a21 21 0 01-13.84-36.81L448.11 256l-109.94-96.19a21 21 0 0127.66-31.61l128 112a21 21 0 010 31.6l-128 112A20.89 20.89 0 01352 389zM208 437a21 21 0 01-20.12-27l96-320a21 21 0 1140.23 12l-96 320A21 21 0 01208 437z"/></svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -0,0 +1 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Go</title><path d="M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path d="M414.39 97.61A224 224 0 1097.61 414.39 224 224 0 10414.39 97.61zM184 208a24 24 0 11-24 24 23.94 23.94 0 0124-24zm167.67 106.17c-12 40.3-50.2 69.83-95.62 69.83s-83.62-29.53-95.72-69.83a8 8 0 017.83-10.17h175.69a8 8 0 017.82 10.17zM328 256a24 24 0 1124-24 23.94 23.94 0 01-24 24z"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -10,5 +10,4 @@ services:
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./server/.env:/.env
|
||||
- ./container_data:/data
|
||||
|
||||
@@ -7,5 +7,4 @@ services:
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./server/.env:/.env
|
||||
- ./container_data:/data
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --plugin-search-dir . --check . && eslint .",
|
||||
"format": "prettier --plugin-search-dir . --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.2.4",
|
||||
"@sveltejs/kit": "^1.15.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.57.1",
|
||||
"@typescript-eslint/parser": "^5.57.1",
|
||||
"eslint": "^8.38.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-svelte3": "^4.0.0",
|
||||
"prettier": "^2.8.7",
|
||||
"prettier-plugin-svelte": "^2.10.0",
|
||||
"svelte": "^3.58.0",
|
||||
"svelte-check": "^3.2.0",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"tslib": "^2.5.0",
|
||||
"typescript": "^5.0.4",
|
||||
"vite": "^4.2.1"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.3.4"
|
||||
}
|
||||
"name": "watcharr",
|
||||
"version": "1.20.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^1.3.1",
|
||||
"@sveltejs/kit": "^1.25.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.9.0",
|
||||
"@typescript-eslint/parser": "^6.9.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-svelte": "^2.34.0",
|
||||
"prettier": "^3.0.3",
|
||||
"prettier-plugin-svelte": "^3.0.3",
|
||||
"svelte": "^4.2.2",
|
||||
"svelte-check": "^3.5.2",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.0.4",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^4.5.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.5.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 421 KiB After Width: | Height: | Size: 409 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 419 KiB |
|
Before Width: | Height: | Size: 425 KiB After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 303 KiB After Width: | Height: | Size: 286 KiB |
@@ -1,7 +0,0 @@
|
||||
# Used to sign JWT tokens. Make sure to make
|
||||
# it strong, just like a very long, complicated password.
|
||||
JWT_SECRET=ME_A_DEVELOPEER
|
||||
|
||||
# Optional: When not set we assume production, should only
|
||||
# be set to DEV when developing the app.
|
||||
MODE=DEV
|
||||
@@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -16,6 +16,7 @@ var (
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
@@ -42,7 +43,7 @@ func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
|
||||
activity := new([]Activity)
|
||||
res := db.Model(&Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
|
||||
if res.Error != nil {
|
||||
println("Failed getting activity from database:", res.Error.Error())
|
||||
slog.Error("Failed getting activity from database", "error", res.Error.Error())
|
||||
return []Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
@@ -55,9 +56,9 @@ func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, err
|
||||
activity := Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data}
|
||||
res := db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
println("Error adding activity to database:", res.Error.Error())
|
||||
slog.Error("Error adding activity to database", "error", res.Error.Error())
|
||||
return Activity{}, errors.New("failed adding new activity to database")
|
||||
}
|
||||
fmt.Printf("%+v\n", activity)
|
||||
slog.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -29,6 +30,16 @@ var (
|
||||
JELLYFIN_USER UserType = 1
|
||||
)
|
||||
|
||||
// User Perms
|
||||
// iota auto increments for us so when adding new
|
||||
// perms, add to bottom as to not change other perm
|
||||
// values.
|
||||
const (
|
||||
PERM_NONE int = 1 << iota
|
||||
PERM_ADMIN
|
||||
PERM_REQUEST_CONTENT
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
// This is incase different users with same name from different services try to signup.
|
||||
type User struct {
|
||||
@@ -40,7 +51,33 @@ type User struct {
|
||||
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
|
||||
// ID of user from the third party service, this will be used purely for lookup of user at signin.
|
||||
ThirdPartyID string `json:"-"`
|
||||
Watched []Watched
|
||||
// Auth token from third party (jellyfin)
|
||||
ThirdPartyAuth string `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
|
||||
// This struct uses pointer to the values, so in update user settings,
|
||||
// we can tell which setting is being updated (if not nil..).
|
||||
type UserSettings struct {
|
||||
// Is profile private
|
||||
Private *bool `gorm:"default:false" json:"private"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
}
|
||||
|
||||
// We use a separate struct for registration to avoid confusion
|
||||
// and possible accidents where we allow a user to pass in a
|
||||
// property from the main User struct that shouldn't be allowed.
|
||||
type UserRegisterRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type UseAdminTokenRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
}
|
||||
|
||||
type JellyfinAuth struct {
|
||||
@@ -53,12 +90,19 @@ type JellyfinAuthResponse struct {
|
||||
ID string `json:"Id"`
|
||||
Name string `json:"Name"`
|
||||
} `json:"User"`
|
||||
AccessToken string `json:"AccessToken"`
|
||||
}
|
||||
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type AvailableAuthProvidersResponse struct {
|
||||
AvailableAuthProviders []string `json:"available"`
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
@@ -68,19 +112,21 @@ type ArgonParams struct {
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
println("AuthRequired middleware hit")
|
||||
slog.Debug("AuthRequired middleware hit")
|
||||
atoken := c.GetHeader("Authorization")
|
||||
// Make sure auth header isn't empty
|
||||
if atoken == "" {
|
||||
println("Returning 401, Authorization header not provided")
|
||||
slog.Warn("Returning 401, Authorization header not provided")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
@@ -88,19 +134,72 @@ func AuthRequired() gin.HandlerFunc {
|
||||
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("AuthRequired failed to parse token", "error", err)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
// If token is valid, go to next handler
|
||||
if claims, ok := token.Claims.(*TokenClaims); ok && token.Valid {
|
||||
println("Token valid", claims.Username)
|
||||
// Check if token issuedAt is from before `timeOfNewLoginRequired`.
|
||||
// Basically just so we can logout old tokens and force relogin...
|
||||
// since new changes require the user login again.
|
||||
timeOfNewLoginRequired, _ := time.Parse(time.RFC822, "18 Aug 23 20:30 UTC")
|
||||
if claims.IssuedAt.Before(timeOfNewLoginRequired) {
|
||||
slog.Info("Token is from before timeOfNewLoginRequired.. returning 401", "token_issued_at", claims.IssuedAt, "time_of_new_login_required", timeOfNewLoginRequired)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("Token is valid", "claims", claims)
|
||||
c.Set("userId", claims.UserID)
|
||||
c.Set("userType", claims.Type)
|
||||
// If db passed, get extra user info and set as variables in req context
|
||||
if db != nil {
|
||||
slog.Debug("AuthRequired: db passed.. getting extra user info")
|
||||
dbUser := new(User)
|
||||
res := db.Where("id = ?", claims.UserID).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("AuthRequired: Failed to select user from database", "error", res.Error)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("AuthRequired: fetched extra user info. Setting vars.", "userThirdPartyId", dbUser.ThirdPartyID, "userThirdPartyAuth", "lol this is censored dude")
|
||||
c.Set("userThirdPartyId", dbUser.ThirdPartyID)
|
||||
c.Set("userThirdPartyAuth", dbUser.ThirdPartyAuth)
|
||||
c.Set("username", dbUser.Username)
|
||||
c.Set("userPermissions", dbUser.Permissions)
|
||||
}
|
||||
c.Next()
|
||||
} else {
|
||||
fmt.Println(err)
|
||||
slog.Error("Token is **not** valid")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func register(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
println("Registering", user.Username)
|
||||
// Admin only middleware (use after AuthRequired with extra info!)
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if hasPermission(perms, PERM_ADMIN) {
|
||||
slog.Debug("AdminRequired: User has permission to access admin only route", "user_id", userId)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("AdminRequired: User denied permission to access admin only route", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
|
||||
if !Config.SIGNUP_ENABLED {
|
||||
slog.Warn("Register called, but signing up is disabled.")
|
||||
return AuthResponse{}, errors.New("registering is disabled")
|
||||
}
|
||||
var user User = User{Username: ur.Username, Password: ur.Password}
|
||||
slog.Info("A user is registering", "username", user.Username)
|
||||
hash, err := hashPassword(user.Password, &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
@@ -115,100 +214,121 @@ func register(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
// Update user obj to replace the plaintext pass with hash
|
||||
user.Password = hash
|
||||
|
||||
// Update user permissions if an initial perm is passed in (1 is default)
|
||||
if initialPerm != 0 && initialPerm != PERM_NONE {
|
||||
slog.Info("User being registered has been given extra initial permissions", "initial_perm", initialPerm)
|
||||
user.Permissions = initialPerm
|
||||
}
|
||||
|
||||
res := db.Create(&user)
|
||||
if res.Error != nil {
|
||||
// If error is because unique contraint failed.. user already exists
|
||||
if strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
println("Unique contraint fail:", res.Error.Error())
|
||||
slog.Error("Registration failed", "error", res.Error.Error(), "error_pretty", "User already exists")
|
||||
return AuthResponse{}, errors.New("User already exists")
|
||||
}
|
||||
panic(err)
|
||||
slog.Error("Registration failed", "error", err, "error_pretty", "Watcharr does not know why this failed, assume database operation failed")
|
||||
return AuthResponse{}, errors.New("unknown error")
|
||||
}
|
||||
|
||||
// Gorm fills our user obj with the ID from db after insert,
|
||||
// just ensure it actually has.
|
||||
if user.ID == 0 {
|
||||
fmt.Println("user.ID not filled out after registration", user.ID)
|
||||
slog.Error("user.ID not filled out after registration", "userId", user.ID)
|
||||
return AuthResponse{}, errors.New("failed to get user id, try login")
|
||||
}
|
||||
|
||||
token, err := signJWT(user)
|
||||
token, err := signJWT(&user)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
slog.Error("Registration: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func registerFirstUser(user *UserRegisterRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
// Ensure no users exist
|
||||
var userCount int64
|
||||
uresp := db.Model(&User{}).Count(&userCount)
|
||||
if uresp.Error != nil {
|
||||
slog.Error("registerFirstUser: User count query failed!", "error", uresp.Error)
|
||||
return AuthResponse{}, errors.New("failed to query db for a count of users")
|
||||
}
|
||||
if userCount != 0 {
|
||||
slog.Warn("registerFirstUser: registered users already exist.")
|
||||
return AuthResponse{}, errors.New("first user already registered")
|
||||
}
|
||||
slog.Info("Registering first user.")
|
||||
return register(user, PERM_ADMIN, db)
|
||||
}
|
||||
|
||||
func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
fmt.Println("Logging in", user.Username)
|
||||
slog.Debug("A User Is Logging In", "username", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND (type IS NULL OR type = 0)", user.Username).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
fmt.Println("Failed to select user from database for login:", res.Error)
|
||||
slog.Error("Failed to select user from database for login", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("User does not exist")
|
||||
}
|
||||
|
||||
match, err := compareHash(user.Password, dbUser.Password)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to compare pass to hash for login:", err)
|
||||
slog.Error("Failed to compare pass to hash for login", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to login")
|
||||
}
|
||||
if !match {
|
||||
fmt.Println("User failed to provide correct password for login:", match)
|
||||
slog.Error("User failed to provide correct password for login", "hash_matched", match)
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
slog.Error("Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
jellyfinHost := os.Getenv("JELLYFIN_HOST")
|
||||
if jellyfinHost == "" {
|
||||
println("Request made to login via Jellyfin, but JELLYFIN_HOST environment variable is not set.")
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
return AuthResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
|
||||
base, err := url.Parse(jellyfinHost + "/Users/AuthenticateByName")
|
||||
base, err := url.Parse(Config.JELLYFIN_HOST + "/Users/AuthenticateByName")
|
||||
if err != nil {
|
||||
println("Failed to parse AuthenticateByName api endpoint url:", err.Error())
|
||||
slog.Error("Failed to parse AuthenticateByName api endpoint url", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Marshall struct as json
|
||||
usrJSON, err := json.Marshal(JellyfinAuth{Username: user.Username, Pw: user.Password})
|
||||
if err != nil {
|
||||
println("Error marshalling JellyfinAuth JSON", err.Error())
|
||||
slog.Error("Error marshalling JellyfinAuth JSON", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to marshal json")
|
||||
}
|
||||
// Run auth request
|
||||
// res, err := http.Post(base.String(), "application/json", bytes.NewBuffer(usrJSON))
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer(usrJSON))
|
||||
if err != nil {
|
||||
println("creating request to jellyfin for auth failed:", err)
|
||||
slog.Error("Creating request to jellyfin for auth failed", "error", err)
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("X-Emby-Authorization", "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor"+user.Username+"\", Version=\"10.8.0\"")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
println("making request to jellyfin for auth failed:", err)
|
||||
slog.Error("making request to jellyfin for auth failed", "error", err)
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
println("Error reading response", err.Error())
|
||||
slog.Error("Error reading jellyfin auth response", "error", err.Error())
|
||||
return AuthResponse{}, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
println("Jellyfin auth non 200 status code:", res.StatusCode, string(body))
|
||||
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
// Process auth response
|
||||
@@ -228,32 +348,81 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
// Record not found, so we should create the user
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.ThirdPartyID = resp.User.ID
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
dbUser.Username = resp.User.Name
|
||||
dbUser.Type = JELLYFIN_USER
|
||||
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
println("Failed to create new user in db from jellyfin response:", err.Error())
|
||||
slog.Error("Failed to create new user in db from jellyfin response", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user from jellyfin")
|
||||
}
|
||||
} else {
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
// If user exists.. update their access token in db
|
||||
if resp.AccessToken != "" {
|
||||
slog.Debug("Jellyfin user login - updating user with new access token")
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
db.Save(&dbUser)
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to sign new jwt:", err)
|
||||
slog.Error("Failed to sign new (jellyfin login) jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
var dbToken Token
|
||||
resp := db.Where("value = ?", req.Token).Take(&dbToken)
|
||||
if resp.Error != nil {
|
||||
slog.Info("useAdminToken failed", "error", "token not found in db")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
if dbToken.Type != TOKENTYPE_ADMIN {
|
||||
slog.Info("useAdminToken failed", "error", "token is of wrong type", "type_wanted", TOKENTYPE_ADMIN, "type_actual", dbToken.Type)
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
dur := time.Since(dbToken.CreatedAt)
|
||||
if dur > tokenMaxAge {
|
||||
slog.Info("useAdminToken failed", "error", "token in db has expired")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
if dbToken.UserID != userId {
|
||||
slog.Info("useAdminToken failed", "error", "token in db is not for this user")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
// Token is valid and for current user.. give user admin.
|
||||
// Incase removing the token after used fails, this is in a transaction so user wont be admin.
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Give user admin
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).Update("permissions", PERM_ADMIN).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete used token
|
||||
if err := tx.Where("value = ?", req.Token).Delete(&Token{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Info("useAdminToken failed", "error", err, "error_pretty", "using token transaction failed")
|
||||
return errors.New("failed to use token")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func signJWT(user *User) (token string, err error) {
|
||||
// Create new jwt with claim data
|
||||
jwt := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Type,
|
||||
jwt.RegisteredClaims{
|
||||
// ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
@@ -348,3 +517,11 @@ func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err erro
|
||||
|
||||
return p, salt, hash, nil
|
||||
}
|
||||
|
||||
func hasPermission(perms int, reqPerm int) bool {
|
||||
// Admins have permission for everything.
|
||||
if perms&PERM_ADMIN == PERM_ADMIN {
|
||||
return true
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
)
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
JWT_SECRET string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to your Jellyfin install
|
||||
// to enable it as an auth provider.
|
||||
JELLYFIN_HOST string `json:",omitempty"`
|
||||
|
||||
// Enable/disable signup functionality.
|
||||
// Set to `false` to disable registering an account.
|
||||
SIGNUP_ENABLED bool `json:",omitempty"`
|
||||
|
||||
// Optional: Provide your own TMDB API Key.
|
||||
// If unprovided, the default Watcharr API key will be used.
|
||||
TMDB_KEY string `json:",omitempty"`
|
||||
|
||||
// Enable/disable debug logging. Useful for when trying
|
||||
// to figure out exactly what the server is doing at a point
|
||||
// of failure.
|
||||
// Set to `true` to enable.
|
||||
DEBUG bool `json:",omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. set defaults here, then `readConfig`
|
||||
// will overwrite if provided in watcharr.json cfg file.
|
||||
Config = ServerConfig{
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
)
|
||||
|
||||
// Read config file
|
||||
// Calls generateConfig if file doesn't exist
|
||||
func readConfig() error {
|
||||
cfg, err := os.Open("./data/watcharr.json")
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Info("Config file doesn't exist... generating.")
|
||||
if err = generateConfig(); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer cfg.Close()
|
||||
jsonParser := json.NewDecoder(cfg)
|
||||
if err = jsonParser.Decode(&Config); err != nil {
|
||||
return err
|
||||
}
|
||||
initFromConfig()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure required config is provided
|
||||
func initFromConfig() error {
|
||||
if Config.JWT_SECRET == "" {
|
||||
log.Fatal("JWT_SECRET missing from config!")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Generate new barebones watcharr.json config file.
|
||||
// Currently only JWT_SECRET is required, so this method
|
||||
// generates a secret.
|
||||
func generateConfig() error {
|
||||
key, err := generateString(64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := ServerConfig{JWT_SECRET: key}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config.JWT_SECRET = cfg.JWT_SECRET
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
}
|
||||
|
||||
// Update server config property
|
||||
func updateConfig(k string, v any) error {
|
||||
slog.Debug("updateConfig", "k", k, "v", v)
|
||||
if v == nil {
|
||||
return errors.New("invalid value")
|
||||
}
|
||||
if k == "JELLYFIN_HOST" {
|
||||
Config.JELLYFIN_HOST = v.(string)
|
||||
} else if k == "SIGNUP_ENABLED" {
|
||||
Config.SIGNUP_ENABLED = v.(bool)
|
||||
} else if k == "TMDB_KEY" {
|
||||
Config.TMDB_KEY = v.(string)
|
||||
} else if k == "DEBUG" {
|
||||
Config.DEBUG = v.(bool)
|
||||
setLoggingLevel()
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
return writeConfig()
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
func writeConfig() error {
|
||||
barej, err := json.MarshalIndent(Config, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -20,7 +21,7 @@ type Content struct {
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
ReleaseDate time.Time `json:"release_date"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
@@ -33,23 +34,48 @@ type Content struct {
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
func transformProviders(c *interface{}, country string) {
|
||||
slog.Debug("transformProviders called", "country", country)
|
||||
if cmap, ok := (*c).(map[string]interface{}); ok {
|
||||
if rmap, ok := cmap["results"].(map[string]interface{}); ok {
|
||||
if val, ok := rmap[country]; ok {
|
||||
slog.Debug("transformProviders: Found country.. overwriting whole object", "new_obj", val)
|
||||
if rvmap, ok := val.(map[string]interface{}); ok {
|
||||
rvmap["country"] = country
|
||||
}
|
||||
*c = val
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find country..", "country", country)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find results property..")
|
||||
}
|
||||
} else {
|
||||
slog.Error("transformProviders: Assertion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBSearchMultiResponse)
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete multi search request!", err.Error())
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(id string) (TMDBMovieDetails, error) {
|
||||
func movieDetails(id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, map[string]string{"append_to_response": "videos"}, &resp)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete movie details request!", err.Error())
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -57,19 +83,20 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete movie cast request!", err.Error())
|
||||
slog.Error("Failed to complete movie cast request!", "error", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete movie cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(id string) (TMDBShowDetails, error) {
|
||||
func tvDetails(id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
resp := new(TMDBShowDetails)
|
||||
err := tmdbRequest("/tv/"+id, map[string]string{"append_to_response": "videos"}, &resp)
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete tv details request!", err.Error())
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -77,7 +104,7 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete tv cast request!", err.Error())
|
||||
slog.Error("Failed to complete tv cast request!", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
@@ -87,7 +114,7 @@ func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error)
|
||||
resp := new(TMDBSeasonDetails)
|
||||
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete season details request!", err.Error())
|
||||
slog.Error("Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
return *resp, nil
|
||||
@@ -97,7 +124,7 @@ func personDetails(id string) (TMDBPersonDetails, error) {
|
||||
resp := new(TMDBPersonDetails)
|
||||
err := tmdbRequest("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete person details request!", err.Error())
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
@@ -107,8 +134,62 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
resp := new(TMDBPersonCombinedCredits)
|
||||
err := tmdbRequest("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
println("Failed to complete person details request!", err.Error())
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
err := tmdbRequest("/discover/movie/", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
resp := new(TMDBDiscoverShows)
|
||||
err := tmdbRequest("/discover/tv/", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func allTrending() (TMDBTrendingAll, error) {
|
||||
resp := new(TMDBTrendingAll)
|
||||
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func upcomingMovies() (TMDBUpcomingMovies, error) {
|
||||
resp := new(TMDBUpcomingMovies)
|
||||
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
resp := new(TMDBUpcomingShows)
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -1,64 +1,76 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.20
|
||||
go 1.21
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cache v1.2.0
|
||||
github.com/gin-contrib/cors v1.4.0
|
||||
github.com/gin-gonic/gin v1.9.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/uptrace/bun v1.1.14
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14
|
||||
golang.org/x/crypto v0.9.0
|
||||
gorm.io/driver/sqlite v1.5.1
|
||||
gorm.io/gorm v1.25.1
|
||||
github.com/uptrace/bun v1.1.16
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.16
|
||||
golang.org/x/crypto v0.14.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.8.3 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.10.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.11.2 // indirect
|
||||
github.com/goccy/go-json v0.10.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/go-playground/validator/v10 v10.15.5 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/google/uuid v1.3.1 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/leodido/go-urn v1.2.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||
github.com/knz/go-libedit v1.10.1 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.0.7 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
|
||||
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.0 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/mod v0.10.0 // indirect
|
||||
golang.org/x/net v0.10.0 // indirect
|
||||
golang.org/x/sys v0.8.0 // indirect
|
||||
golang.org/x/text v0.9.0 // indirect
|
||||
golang.org/x/tools v0.9.1 // indirect
|
||||
google.golang.org/protobuf v1.28.1 // indirect
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
golang.org/x/mod v0.13.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
golang.org/x/text v0.13.0 // indirect
|
||||
golang.org/x/tools v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.3.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
modernc.org/libc v1.22.6 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/cc/v3 v3.41.0 // indirect
|
||||
modernc.org/cc/v4 v4.2.1 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.15 // indirect
|
||||
modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d // indirect
|
||||
modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083 // indirect
|
||||
modernc.org/libc v1.24.1 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/opt v0.1.3 // indirect
|
||||
modernc.org/sqlite v1.22.1 // indirect
|
||||
modernc.org/strutil v1.1.3 // indirect
|
||||
modernc.org/sqlite v1.26.0 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d h1:pVrfxiGfwelyab6n21ZBkbkmbevaf+WvMIiR7sr97hw=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20220106215444-fb4bf637b56d/go.mod h1:H0wQNHz2YrLsuXOZozoeDmnHXkNCRmMW0gwFWDfEZDA=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous=
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.8.0 h1:ea0Xadu+sHlu7x5O3gKhRpQ1IKiMrSiHttPF0ybECuA=
|
||||
github.com/bytedance/sonic v1.8.0/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/bytedance/sonic v1.8.3 h1:pf6fGl5eqWYKkx1RcD4qpuX+BIUaduv/wTm5ekWJ80M=
|
||||
github.com/bytedance/sonic v1.8.3/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.10.0 h1:qtNZduETEIWJVIyDl01BeNxur2rW9OwTQ/yBqFRkKEk=
|
||||
github.com/bytedance/sonic v1.10.0/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
|
||||
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
|
||||
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
|
||||
github.com/gin-contrib/cors v1.4.0 h1:oJ6gwtUl3lqV0WEIwM/LxPF1QZ5qe2lGWdY2+bz7y0g=
|
||||
github.com/gin-contrib/cors v1.4.0/go.mod h1:bs9pNM0x/UsmHPBWT2xZz9ROh8xYjYkiURUfmBoMlcs=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
@@ -18,6 +38,8 @@ github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm
|
||||
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
|
||||
github.com/gin-gonic/gin v1.9.0 h1:OjyFBKICoexlu99ctXNR2gg+c5pKrKMuyjgARg9qeY8=
|
||||
github.com/gin-gonic/gin v1.9.0/go.mod h1:W1Me9+hsUSyj3CePGrd1/QrKJMSJ1Tu/0hFEH89961k=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -28,18 +50,28 @@ github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91
|
||||
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
|
||||
github.com/go-playground/validator/v10 v10.11.2 h1:q3SHpufmypg+erIExEKUmsgmhDTyhcJ38oeKGACXohU=
|
||||
github.com/go-playground/validator/v10 v10.11.2/go.mod h1:NieE624vt4SCTJtD87arVLvdmjPAeV8BQlHtMnw9D7s=
|
||||
github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjAR7VgKoNB6ryXfw=
|
||||
github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
|
||||
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.0 h1:mXKd9Qw4NuzShiRlOXKews24ufknHO7gx30lsDyokKA=
|
||||
github.com/goccy/go-json v0.10.0/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1 h1:tDQ1LjKga657layZ4JLsRdxgvupebc0xuPwRNuTfUgs=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0-rc.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
|
||||
github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
|
||||
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
@@ -55,6 +87,10 @@ github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBF
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1 h1:0pHpWtx9vcvC0xGZqEQlQdfSQs7WRlAjuPvk3fOZDCo=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -65,6 +101,8 @@ github.com/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
|
||||
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
|
||||
github.com/leodido/go-urn v1.2.2 h1:7z68G0FCGvDk646jz1AelTYNYWrTNm0bEcFAo147wt4=
|
||||
github.com/leodido/go-urn v1.2.2/go.mod h1:kUaIbLZWttglzwNuG0pgsh5vuV6u2YcGBYz1hIPjtOQ=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
|
||||
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -77,6 +115,10 @@ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
@@ -88,11 +130,17 @@ github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvI
|
||||
github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
|
||||
github.com/pelletier/go-toml/v2 v2.0.7 h1:muncTPStnKRos5dpVKULv2FVd4bMOhNePj9CjgDb8Us=
|
||||
github.com/pelletier/go-toml/v2 v2.0.7/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/rwtodd/Go.Sed v0.0.0-20210816025313-55464686f9ef/go.mod h1:8AEUvGVi2uQ5b24BIhcr0GCcpd/RNAFWaN2CJFrWIIQ=
|
||||
@@ -106,6 +154,7 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
@@ -116,26 +165,40 @@ github.com/ugorji/go/codec v1.2.9 h1:rmenucSohSTiyL09Y+l2OCk+FrMxGMzho2+tjr5ticU
|
||||
github.com/ugorji/go/codec v1.2.9/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.2.10 h1:eimT6Lsr+2lzmSZxPhLFoOWFmQqwk0fllJJ5hEbTXtQ=
|
||||
github.com/ugorji/go/codec v1.2.10/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/uptrace/bun v1.1.12 h1:sOjDVHxNTuM6dNGaba0wUuz7KvDE1BmNu9Gqs2gJSXQ=
|
||||
github.com/uptrace/bun v1.1.12/go.mod h1:NPG6JGULBeQ9IU6yHp7YGELRa5Agmd7ATZdz4tGZ6z0=
|
||||
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
|
||||
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
|
||||
github.com/uptrace/bun v1.1.16 h1:cn9cgEMFwcyYRsQLfxCRMUxyK1WaHwOVrR3TvzEFZ/A=
|
||||
github.com/uptrace/bun v1.1.16/go.mod h1:7HnsMRRvpLFUcquJxp22JO8PsWKpFQO/gNXqqsuGWg8=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12 h1:Ud31nqZmebcQpl151nb108+vtcpxJ7kfXmbPYbALBiI=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.12/go.mod h1:Pwg7s31BdF3PMBlWTnYkEn2I9ASsvatt1Ln/AERCTV4=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16 h1:gbc9BP/e4sNOB9VBj+Si46dpOz2oktmZPidkda92GYY=
|
||||
github.com/uptrace/bun/dialect/sqlitedialect v1.1.16/go.mod h1:YNezpK7fIn5Wa2WGmTCZ/nEyiswcXmuT4iNWADeL1x4=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12 h1:GMbSa7Pjjk4kjF8XURz5uMLe2PbN98e6t00sp0rx2Eo=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.12/go.mod h1:u67g2ewzoMDCCAqjliHAM/BJjEXfoExXlFXhx3TnXRs=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14 h1:DFPUJ6KjDP2myjq15gtYYNngmAFMww1Y2UFZv4tbUw8=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.14/go.mod h1:5BFN7V6Sm37Tn7UE4FWNm/F6V3iJPUzAJ7QyRwA5b1k=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.16 h1:dhXrdXQegGSM+Jk07s9p1Y575DMhCKl7wBkss8kgM+8=
|
||||
github.com/uptrace/bun/driver/sqliteshim v1.1.16/go.mod h1:7fJaPqaiSvnWytQcMwVv1ZcjUJh7tGDonO/zq3O6RRI=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
|
||||
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.0 h1:hRM0digJwyR6vll33NNAwCFguy5JuBD6jxDmQP3l608=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.0/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
|
||||
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
|
||||
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.5.0 h1:U/0M97KRkSFvyD/3FSmdP5W5swImpNgle/EHFhOsQPE=
|
||||
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
|
||||
@@ -143,12 +206,19 @@ golang.org/x/crypto v0.7.0 h1:AvwMYaRytfdeVt3u6mLaxYtErKYjxA2OXjJ1HHq6t3A=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.9.0 h1:LF6fAI+IutBocDJ2OT0Q1g8plpYljMZ4+lty+dsqw3g=
|
||||
golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0=
|
||||
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
|
||||
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
|
||||
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
|
||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
||||
golang.org/x/exp v0.0.0-20181106170214-d68db9428509/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
|
||||
golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY=
|
||||
golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
@@ -156,6 +226,10 @@ golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14=
|
||||
golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -168,6 +242,10 @@ golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
|
||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
@@ -177,20 +255,30 @@ golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc=
|
||||
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
|
||||
golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
|
||||
golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc=
|
||||
golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -200,6 +288,10 @@ gorm.io/driver/sqlite v1.4.4 h1:gIufGoR0dQzjkyqDyYSCvsYR6fba1Gw5YKDqKeChxFc=
|
||||
gorm.io/driver/sqlite v1.4.4/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/driver/sqlite v1.5.1 h1:hYyrLkAWE71bcarJDPdZNTLWtr8XrSjOWyjUYI6xdL4=
|
||||
gorm.io/driver/sqlite v1.5.1/go.mod h1:7MZZ2Z8bqyfSQA1gYEV6MagQWj3cpUkJj9Z+d1HEMEQ=
|
||||
gorm.io/driver/sqlite v1.5.2 h1:TpQ+/dqCY4uCigCFyrfnrJnrW9zjpelWVoEVNy5qJkc=
|
||||
gorm.io/driver/sqlite v1.5.2/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.6 h1:wy98aq9oFEetsc4CAbKD2SoBCdMzsbSIvSUUFJuHi5s=
|
||||
gorm.io/gorm v1.24.6/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
@@ -207,34 +299,64 @@ gorm.io/gorm v1.25.0 h1:+KtYtb2roDz14EQe4bla8CbQlmb9dN3VejSai3lprfU=
|
||||
gorm.io/gorm v1.25.0/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.1 h1:nsSALe5Pr+cM3V1qwwQ7rOkw+6UeLrX5O4v3llhHa64=
|
||||
gorm.io/gorm v1.25.1/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.3 h1:zi4rHZj1anhZS2EuEODMhDisGy+Daq9jtPrNGgbQYD8=
|
||||
gorm.io/gorm v1.25.3/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
|
||||
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
lukechampine.com/uint128 v1.3.0 h1:cDdUVfRwDUDovz610ABgFD17nXD4/uDgVHl2sC3+sbo=
|
||||
lukechampine.com/uint128 v1.3.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
|
||||
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
|
||||
modernc.org/cc/v3 v3.41.0 h1:QoR1Sn3YWlmA1T4vLaKZfawdVtSiGx8H+cEojbC7v1Q=
|
||||
modernc.org/cc/v3 v3.41.0/go.mod h1:Ni4zjJYJ04CDOhG7dn640WGfwBzfE0ecX8TyMB0Fv0Y=
|
||||
modernc.org/cc/v4 v4.2.1 h1:xwwaXFwiPaVZpGRMd19NPLsaiNyNBO8oChey4501g1M=
|
||||
modernc.org/cc/v4 v4.2.1/go.mod h1:0O8vuqhQfwBy+piyfEjzWIUGV4I3TPsXSf0W05+lgN8=
|
||||
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=
|
||||
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
|
||||
modernc.org/ccgo/v3 v3.16.15 h1:KbDR3ZAVU+wiLyMESPtbtE/Add4elztFyfsWoNTgxS0=
|
||||
modernc.org/ccgo/v3 v3.16.15/go.mod h1:yT7B+/E2m43tmMOT51GMoM98/MtHIcQQSleGnddkUNI=
|
||||
modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d h1:3yB/pQNL5kVPDifGFqoZjeRxf8m0+Us15rB7ertNASQ=
|
||||
modernc.org/ccgo/v4 v4.0.0-20230612200659-63de3e82e68d/go.mod h1:austqj6cmEDRfewsUvmGmyIgsI/Nq87oTXlfTgY85Fc=
|
||||
modernc.org/fileutil v1.0.0/go.mod h1:JHsWpkrk/CnVV1H/eGlFf85BEpfkrp56ro8nojIq9Q8=
|
||||
modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083 h1:rGoLVwiOxdeVkGYMOF/8Pw7xpDd3OqScJU/tqHgvY1c=
|
||||
modernc.org/gc/v2 v2.1.2-0.20220923113132-f3b5abcf8083/go.mod h1:Zt5HLUW0j+l02wj99UsPs+1DOFwwsGnqfcw+BGyyP/A=
|
||||
modernc.org/lex v1.1.0/go.mod h1:+ojes+j0JYCaqwKYCBjcUavscJHmWFKvViUTMU4VjLA=
|
||||
modernc.org/lexer v1.0.0/go.mod h1:F/Dld0YKYdZCLQ7bD0USbWL4YKCyTDRDHiDTOs0q0vk=
|
||||
modernc.org/libc v1.22.2 h1:4U7v51GyhlWqQmwCHj28Rdq2Yzwk55ovjFrdPjs8Hb0=
|
||||
modernc.org/libc v1.22.2/go.mod h1:uvQavJ1pZ0hIoC/jfqNoMLURIMhKzINIWypNM17puug=
|
||||
modernc.org/libc v1.22.3 h1:D/g6O5ftAfavceqlLOFwaZuA5KYafKwmr30A6iSqoyY=
|
||||
modernc.org/libc v1.22.3/go.mod h1:MQrloYP209xa2zHome2a8HLiLm6k0UT8CoHpV74tOFw=
|
||||
modernc.org/libc v1.22.6 h1:cbXU8R+A6aOjRuhsFh3nbDWXO/Hs4ClJRXYB11KmPDo=
|
||||
modernc.org/libc v1.22.6/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/libc v1.24.1 h1:uvJSeCKL/AgzBo2yYIPPTy82v21KgGnizcGYfBHaNuM=
|
||||
modernc.org/libc v1.24.1/go.mod h1:FmfO1RLrU3MHJfyi9eYYmZBfi/R+tqZ6+hQ3yQQUkak=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/scannertest v1.0.0/go.mod h1:9qnOCV+wSvq1o9hcOPNwRorND4qpZdtmTvmcdKyN3iE=
|
||||
modernc.org/sqlite v1.20.4 h1:J8+m2trkN+KKoE7jglyHYYYiaq5xmz2HoHJIiBlRzbE=
|
||||
modernc.org/sqlite v1.20.4/go.mod h1:zKcGyrICaxNTMEHSr1HQ2GUraP0j+845GYw37+EyT6A=
|
||||
modernc.org/sqlite v1.21.0 h1:4aP4MdUf15i3R3M2mx6Q90WHKz3nZLoz96zlB6tNdow=
|
||||
modernc.org/sqlite v1.21.0/go.mod h1:XwQ0wZPIh1iKb5mkvCJ3szzbhk+tykC8ZWqTRTgYRwI=
|
||||
modernc.org/sqlite v1.22.1 h1:P2+Dhp5FR1RlVRkQ3dDfCiv3Ok8XPxqpe70IjYVA9oE=
|
||||
modernc.org/sqlite v1.22.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
modernc.org/sqlite v1.26.0 h1:SocQdLRSYlA8W99V8YH0NES75thx19d9sB/aFc4R8Lw=
|
||||
modernc.org/sqlite v1.26.0/go.mod h1:FL3pVXie73rg3Rii6V/u5BoHlSoyeZeIgKZEgHARyCU=
|
||||
modernc.org/strutil v1.1.3 h1:fNMm+oJklMGYfU9Ylcywl0CO5O6nTfaowNsh2wpPjzY=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
b64 "encoding/base64"
|
||||
)
|
||||
|
||||
// Generate a random string
|
||||
func generateString(len int) (string, error) {
|
||||
key := make([]byte, len)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return b64.StdEncoding.EncodeToString([]byte(key)), nil
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ImportResponseType string
|
||||
|
||||
var (
|
||||
// Successful import
|
||||
IMPORT_SUCCESS ImportResponseType = "IMPORT_SUCCESS"
|
||||
// Import failed for reasons user cant fix
|
||||
IMPORT_FAILED ImportResponseType = "IMPORT_FAILED"
|
||||
// Import query returned multiple results, user must decide
|
||||
IMPORT_MULTI ImportResponseType = "IMPORT_MULTI"
|
||||
// Import query returned zero results, user must provide more info
|
||||
IMPORT_NOTFOUND ImportResponseType = "IMPORT_NOTFOUND"
|
||||
// Item already exists so couldn't import (unique constraint hit when adding)
|
||||
IMPORT_EXISTS ImportResponseType = "IMPORT_EXISTS"
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
Type ImportResponseType `json:"type"`
|
||||
Results []TMDBSearchMultiResults `json:"results"`
|
||||
Match TMDBSearchMultiResults `json:"match"`
|
||||
// On success this will be filled with the new watched entry
|
||||
WatchedEntry Watched `json:"watchedEntry"`
|
||||
}
|
||||
|
||||
func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse, error) {
|
||||
// If tmdbId and type passed in request body
|
||||
// we dont need to use a search tmdb request.
|
||||
// Retrieve the details directly.
|
||||
if ar.TmdbID != 0 && (ar.Type == MOVIE || ar.Type == SHOW) {
|
||||
tid := strconv.Itoa(ar.TmdbID)
|
||||
if ar.Type == MOVIE {
|
||||
cr, err := movieDetails(tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("movie details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of movie", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, MOVIE)
|
||||
} else if ar.Type == SHOW {
|
||||
cr, err := tvDetails(tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("tv details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of tv", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, SHOW)
|
||||
}
|
||||
}
|
||||
sr, err := searchContent(ar.Name)
|
||||
if err != nil {
|
||||
slog.Error("import: content search failed", "error", err)
|
||||
return ImportResponse{}, errors.New("Content search failed")
|
||||
}
|
||||
pMatches := []TMDBSearchMultiResults{}
|
||||
for _, r := range sr.Results {
|
||||
if r.MediaType != "person" {
|
||||
pMatches = append(pMatches, r)
|
||||
}
|
||||
}
|
||||
resLen := len(pMatches)
|
||||
slog.Debug("import: potential matches", "num_found", resLen)
|
||||
if resLen <= 0 {
|
||||
slog.Debug("import: returning IMPORT_NOTFOUND")
|
||||
return ImportResponse{Type: IMPORT_NOTFOUND}, nil
|
||||
} else if resLen > 1 {
|
||||
slog.Debug("import: multiple results found")
|
||||
// If there are multiple responses, but only one item
|
||||
// from the results is a 100% match for the imported
|
||||
// items name, then consider successful match with that.
|
||||
var perfectMatch TMDBSearchMultiResults
|
||||
for _, r := range pMatches {
|
||||
itemName := r.Name
|
||||
if itemName == "" {
|
||||
itemName = r.Title
|
||||
}
|
||||
if strings.EqualFold(itemName, ar.Name) {
|
||||
slog.Debug("import: multiple results processing: found a perfectMatch", "match", r)
|
||||
if perfectMatch.ID != 0 {
|
||||
// If perfect match has been set before..
|
||||
// quit looking and just show all results.
|
||||
slog.Debug("import: multiple results processing: Second perfectMatch found.. returning all results")
|
||||
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
|
||||
}
|
||||
perfectMatch = r
|
||||
}
|
||||
}
|
||||
// If one perfect match found, import it
|
||||
if perfectMatch.ID != 0 {
|
||||
slog.Debug("import: importing from perfect match")
|
||||
return successfulImport(db, userId, perfectMatch.ID, ContentType(perfectMatch.MediaType))
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
|
||||
} else {
|
||||
slog.Debug("import: success.. only found one result")
|
||||
return successfulImport(db, userId, pMatches[0].ID, ContentType(pMatches[0].MediaType))
|
||||
}
|
||||
}
|
||||
|
||||
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType) (ImportResponse, error) {
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: contentId,
|
||||
ContentType: contentType,
|
||||
}, IMPORTED_WATCHED)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("successfulImport: unique constraint hit.. show must already be on watch list", "error", err)
|
||||
return ImportResponse{Type: IMPORT_EXISTS}, nil
|
||||
}
|
||||
slog.Error("successfulImport: Failed to add content as watched", "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
type JellyfinItemSearchResponse struct {
|
||||
Items []JellyfinItems `json:"Items"`
|
||||
}
|
||||
|
||||
type JellyfinItems struct {
|
||||
Type string `json:"Type"`
|
||||
ServerID string `json:"ServerId"`
|
||||
Id string `json:"Id"`
|
||||
ProviderIds struct {
|
||||
Tmdb string `json:"Tmdb"`
|
||||
} `json:"ProviderIds"`
|
||||
}
|
||||
|
||||
type JFContentFindResponse struct {
|
||||
HasContent bool `json:"hasContent"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
|
||||
return errors.New("jellyfin not enabled")
|
||||
}
|
||||
slog.Debug("jellyfinAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse(Config.JELLYFIN_HOST)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
// Run get request
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest(method, base.String(), bytes.NewBuffer([]byte{}))
|
||||
if err != nil {
|
||||
slog.Error("Creating request to jellyfin failed", "error", err)
|
||||
return errors.New("request failed")
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
authHeader := "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor" + username + "\", Version=\"10.8.0\""
|
||||
if userToken != "" {
|
||||
authHeader += ", Token=\"" + userToken + "\""
|
||||
}
|
||||
req.Header.Add("X-Emby-Authorization", authHeader)
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("making request to jellyfin for auth failed", "error", err)
|
||||
return errors.New("request failed")
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
slog.Error("Error reading jellyfin auth response", "error", err.Error())
|
||||
return err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("Jellyfin auth non 200 status code", "status_code", res.StatusCode, "error", string(body))
|
||||
return errors.New("incorrect details")
|
||||
}
|
||||
// Unmarshal response
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jellyfinContentFind(
|
||||
userId uint,
|
||||
userType UserType,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
contentType string,
|
||||
contentName string,
|
||||
contentTmdbId string,
|
||||
) (JFContentFindResponse, error) {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
return JFContentFindResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
if userType != JELLYFIN_USER || userThirdPartyId == "" {
|
||||
slog.Error("User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
|
||||
return JFContentFindResponse{}, errors.New("not jellyfin user")
|
||||
}
|
||||
if userThirdPartyAuth == "" {
|
||||
slog.Error("User has no thirdPartyAuth token..")
|
||||
return JFContentFindResponse{}, errors.New("user has no jellyfin auth token")
|
||||
}
|
||||
if contentType == "" || contentName == "" {
|
||||
slog.Error("Bad request", "content_type", contentType, "content_name", contentName)
|
||||
return JFContentFindResponse{}, errors.New("content type or name not provided")
|
||||
}
|
||||
if contentType == "tv" {
|
||||
contentType = "series"
|
||||
}
|
||||
|
||||
resp := new(JellyfinItemSearchResponse)
|
||||
err := jellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
"searchTerm": contentName,
|
||||
"IncludePeople": "false",
|
||||
"IncludeMedia": "true",
|
||||
"IncludeGenres": "false",
|
||||
"IncludeStudios": "false",
|
||||
"IncludeArtists": "false",
|
||||
"IncludeItemTypes": contentType,
|
||||
"Limit": "5",
|
||||
"Fields": "ProviderIds",
|
||||
"Recursive": "true",
|
||||
"EnableTotalRecordCount": "false",
|
||||
"ImageTypeLimit": "0",
|
||||
},
|
||||
username,
|
||||
userThirdPartyAuth,
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinContentFind: Jellyfin API request failed", "error", err)
|
||||
return JFContentFindResponse{}, errors.New("failed to get jellyfin response")
|
||||
}
|
||||
|
||||
// Find true match from jf search results
|
||||
ret := new(JFContentFindResponse)
|
||||
ret.HasContent = false
|
||||
ret.Url = ""
|
||||
for _, i := range resp.Items {
|
||||
if i.ProviderIds.Tmdb == contentTmdbId {
|
||||
ret.HasContent = true
|
||||
ret.Url = Config.JELLYFIN_HOST + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
|
||||
}
|
||||
}
|
||||
return *ret, nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Location middleware
|
||||
func WhereaboutsRequired() gin.HandlerFunc {
|
||||
// TODO: This should also take into account a user
|
||||
// preference (of location.. which also needs to be
|
||||
// added) and server preference (env var). The US is
|
||||
// good enough for now as a default.
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("WhereaboutsRequired middleware hit", "country", "US")
|
||||
c.Set("userCountry", "US")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -18,13 +19,13 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
user := new(User)
|
||||
res := db.Model(&User{}).Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
println("Failed to get profile:", res.Error.Error())
|
||||
slog.Error("Failed to get profile:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
println("Profile: Failed to get watched for processing:", res.Error.Error())
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -12,6 +16,11 @@ type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type KeyValueRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type BaseRouter struct {
|
||||
db *gorm.DB
|
||||
rg *gin.RouterGroup
|
||||
@@ -24,17 +33,47 @@ func newBaseRouter(db *gorm.DB, rg *gin.RouterGroup) *BaseRouter {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addContentRoutes() {
|
||||
content := b.rg.Group("/content").Use(AuthRequired())
|
||||
// Since we cannot remove these setup routes after they are registered,
|
||||
// each route/service should ensure we are still in setup before continuing.
|
||||
// After server restart, these routes shouldn't exist if setup finished
|
||||
// (currently it is finished if a user is created).
|
||||
//
|
||||
// Each controller can check ServerInSetup var first, then each service
|
||||
// can double check what it needs to (eg create_admin service, registerFirstUser,
|
||||
// will check that no users exist).
|
||||
func (b *BaseRouter) addSetupRoutes() {
|
||||
setup := b.rg.Group("/setup")
|
||||
|
||||
// Get trending content
|
||||
// content.GET("/", func(c *gin.Context) {
|
||||
// c.JSON(http.StatusOK, getWatched(b.db))
|
||||
// })
|
||||
setup.POST("/create_admin", func(c *gin.Context) {
|
||||
if !ServerInSetup {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "not in setup"})
|
||||
return
|
||||
}
|
||||
var user UserRegisterRequest
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := registerFirstUser(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
} else {
|
||||
// Set in setup to false after first user registered successfully
|
||||
ServerInSetup = false
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addContentRoutes() {
|
||||
content := b.rg.Group("/content").Use(AuthRequired(nil))
|
||||
exp := time.Hour * 24
|
||||
store := persistence.NewInMemoryStore(exp)
|
||||
|
||||
// Search for content
|
||||
content.GET("/:query", func(c *gin.Context) {
|
||||
println(c.Param("query"))
|
||||
content.GET("/:query", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
// println(c.Param("query"))
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -45,24 +84,24 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get movie details (for movie page)
|
||||
content.GET("/movie/:id", func(c *gin.Context) {
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := movieDetails(c.Param("id"))
|
||||
content, err := movieDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get movie cast
|
||||
content.GET("/movie/:id/credits", func(c *gin.Context) {
|
||||
content.GET("/movie/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -73,24 +112,24 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.GET("/tv/:id", func(c *gin.Context) {
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := tvDetails(c.Param("id"))
|
||||
content, err := tvDetails(c.Param("id"), c.MustGet("userCountry").(string), map[string]string{"append_to_response": "videos,watch/providers,similar"})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", func(c *gin.Context) {
|
||||
content.GET("/tv/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -101,10 +140,10 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -115,10 +154,10 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", func(c *gin.Context) {
|
||||
content.GET("/person/:id", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -129,10 +168,10 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Get person credits
|
||||
content.GET("/person/:id/credits", func(c *gin.Context) {
|
||||
content.GET("/person/:id/credits", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -143,23 +182,88 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
})
|
||||
}))
|
||||
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content, err := discoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content, err := discoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content, err := allTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content, err := upcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", cache.CachePage(store, exp, func(c *gin.Context) {
|
||||
content, err := upcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addWatchedRoutes() {
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired())
|
||||
watched := b.rg.Group("/watched").Use(AuthRequired(nil))
|
||||
|
||||
watched.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
c.JSON(http.StatusOK, getWatched(b.db, userId))
|
||||
})
|
||||
|
||||
watched.GET(":id/:username", func(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("getPublicWatched route failed to convert id param to uint", "id", id)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getPublicWatched(b.db, uint(id), c.Param("username"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addWatched(b.db, userId, ar)
|
||||
response, err := addWatched(b.db, userId, ar, ADDED_WATCHED)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -212,7 +316,7 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
activity := b.rg.Group("/activity").Use(AuthRequired())
|
||||
activity := b.rg.Group("/activity").Use(AuthRequired(nil))
|
||||
|
||||
activity.GET(":watchedId", func(c *gin.Context) {
|
||||
watchedId, err := strconv.ParseUint(c.Param("watchedId"), 10, 32)
|
||||
@@ -281,9 +385,9 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user User
|
||||
var user UserRegisterRequest
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := register(&user, b.db)
|
||||
response, err := register(&user, PERM_NONE, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -296,12 +400,48 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
|
||||
// Get available auth providers
|
||||
auth.GET("/available", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, AvailableAuthProviders)
|
||||
availableAuthProviders := []string{}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
})
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.Use(AuthRequired(nil)).GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := createOneUseToken(b.db, TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
// Use admin token
|
||||
auth.Use(AuthRequired(nil)).POST("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := useAdminToken(&atr, b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addProfileRoutes() {
|
||||
profile := b.rg.Group("/profile").Use(AuthRequired())
|
||||
profile := b.rg.Group("/profile").Use(AuthRequired(nil))
|
||||
|
||||
// Get user profile details
|
||||
profile.GET("", func(c *gin.Context) {
|
||||
@@ -314,3 +454,127 @@ func (b *BaseRouter) addProfileRoutes() {
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJellyfinRoutes() {
|
||||
jf := b.rg.Group("/jellyfin").Use(AuthRequired(b.db))
|
||||
|
||||
// Check if jf has item
|
||||
jf.GET("/:type/:name/:tmdbId", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
username := c.MustGet("username").(string)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
response, err := jellyfinContentFind(userId, userType, username, userThirdPartyId, userThirdPartyAuth, c.Param("type"), c.Param("name"), c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addUserRoutes() {
|
||||
u := b.rg.Group("/user").Use(AuthRequired(b.db))
|
||||
|
||||
// Get current user info
|
||||
u.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getUserInfo(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Update current user settings
|
||||
u.POST("/update", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ur UserSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
response, err := userUpdate(b.db, userId, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get current user setting
|
||||
u.GET("/settings", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := userGetSettings(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Search users
|
||||
u.GET("/search/:query", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := userSearch(b.db, userId, c.Param("query"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addImportRoutes() {
|
||||
imprt := b.rg.Group("/import").Use(AuthRequired(nil))
|
||||
|
||||
imprt.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar ImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := importContent(b.db, userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addServerRoutes() {
|
||||
server := b.rg.Group("/server").Use(AuthRequired(b.db), AdminRequired())
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
server.GET("/config", func(c *gin.Context) {
|
||||
// Return new ServerConfig with only the fields we want to show in settings ui
|
||||
c.JSON(http.StatusOK, ServerConfig{
|
||||
SIGNUP_ENABLED: Config.SIGNUP_ENABLED,
|
||||
JELLYFIN_HOST: Config.JELLYFIN_HOST,
|
||||
TMDB_KEY: Config.TMDB_KEY,
|
||||
DEBUG: Config.DEBUG,
|
||||
})
|
||||
})
|
||||
|
||||
// Update config
|
||||
server.POST("/config", func(c *gin.Context) {
|
||||
var ur KeyValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := updateConfig(ur.Key, ur.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(db *gorm.DB) {
|
||||
taskRunInterval := 1 * time.Minute
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Runs funcs that are in the place where we are cleaning.
|
||||
// Bit cleaner and we can keep the related code close to its home.
|
||||
cleanupTokens(db)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
@@ -86,7 +87,9 @@ type TMDBMovieDetails struct {
|
||||
Video bool `json:"video"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
Similar TMDBMovieSimilar `json:"similar"`
|
||||
}
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
@@ -139,7 +142,15 @@ type TMDBShowDetails struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
}
|
||||
|
||||
type WatchProvider struct {
|
||||
ProviderID int `json:"provider_id"`
|
||||
ProviderName string `json:"provider_name"`
|
||||
DisplayPriority int `json:"display_priority"`
|
||||
}
|
||||
|
||||
type TMDBContentVideos struct {
|
||||
@@ -208,6 +219,50 @@ type TMDBSeasonDetails struct {
|
||||
SeasonNumber int `json:"season_number"`
|
||||
}
|
||||
|
||||
type TMDBShowSimilar struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBMovieSimilar struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBPersonDetails struct {
|
||||
Birthday string `json:"birthday"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
@@ -287,8 +342,132 @@ type TMDBContentCredits struct {
|
||||
} `json:"crew"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverMovies struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBDiscoverShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBTrendingAll struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title,omitempty"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title,omitempty"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date,omitempty"`
|
||||
Video bool `json:"video,omitempty"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
Name string `json:"name,omitempty"`
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingMovies struct {
|
||||
Dates struct {
|
||||
Maximum string `json:"maximum"`
|
||||
Minimum string `json:"minimum"`
|
||||
} `json:"dates"`
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBUpcomingShows struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
func getTMDBKey() string {
|
||||
if Config.TMDB_KEY != "" {
|
||||
return Config.TMDB_KEY
|
||||
}
|
||||
return "d047fa61d926371f277e7a83c9c4ff2c"
|
||||
}
|
||||
|
||||
func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
println("tmdbAPIRequest:", ep)
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse("https://api.themoviedb.org/3")
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to parse api uri")
|
||||
@@ -299,7 +478,7 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
params.Add("api_key", "d047fa61d926371f277e7a83c9c4ff2c")
|
||||
params.Add("api_key", getTMDBKey())
|
||||
params.Add("language", "en-US")
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
@@ -319,7 +498,7 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
println("TMDB non 200 status code:", res.StatusCode)
|
||||
slog.Error("TMDB non 200 status code:", "status_code", res.StatusCode)
|
||||
return nil, errors.New(string(body))
|
||||
}
|
||||
return body, nil
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TokenType string
|
||||
|
||||
var (
|
||||
TOKENTYPE_ADMIN TokenType = "ADMIN"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Value string `gorm:"not null"`
|
||||
Type TokenType `gorm:"not null"`
|
||||
UserID uint `gorm:"not null"`
|
||||
}
|
||||
|
||||
const tokenMaxAge = 2 * time.Minute
|
||||
|
||||
func createOneUseToken(db *gorm.DB, t TokenType, userId uint) (string, error) {
|
||||
token, err := generateString(8)
|
||||
if err != nil {
|
||||
slog.Error("createOneUseToken: Failed to generate string!", "error", err)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
res := db.Create(&Token{Type: t, Value: token, UserID: userId})
|
||||
if res.Error != nil {
|
||||
slog.Error("createOneUseToken: Failed to insert token into db!", "error", res.Error)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Cleans up tokens older than 2m.
|
||||
func cleanupTokens(db *gorm.DB) {
|
||||
slog.Debug("cleanupTokens: Cleaning up old tokens from db")
|
||||
twoMinsAgo := time.Now().Add(-tokenMaxAge)
|
||||
resp := db.Where("created_at < ?", twoMinsAgo).Delete(&Token{})
|
||||
if resp.Error != nil {
|
||||
slog.Error("cleanupTokens: Failed to run DELETE on old tokens!", "error", resp.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Public user details for search results
|
||||
type PublicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
// Private user details, for returning users details to themselves
|
||||
type PrivateUser struct {
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
}
|
||||
|
||||
// Update user settings
|
||||
func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId, "ur", ur)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user update failed", "user_id", userId, "error", "failed to retrieve user from database")
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
if ur.HideSpoilers != nil {
|
||||
user.HideSpoilers = ur.HideSpoilers
|
||||
}
|
||||
if ur.Private != nil {
|
||||
user.Private = ur.Private
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
|
||||
}
|
||||
|
||||
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId)
|
||||
user := new(User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get failed", "user_id", userId, "error", "failed to retrieve user from database")
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
return UserSettings{Private: user.Private, HideSpoilers: user.HideSpoilers}, nil
|
||||
}
|
||||
|
||||
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
|
||||
slog.Debug("user search request running", "query", q)
|
||||
users := new([]PublicUser)
|
||||
res := db.Where("private = 0 AND username LIKE ? AND id != ?", "%"+q+"%", currentUsersId).Table("users").Find(&users)
|
||||
if res.Error != nil {
|
||||
slog.Error("user search failed", "error", "failed to query database")
|
||||
return []PublicUser{}, errors.New("failed to find users")
|
||||
}
|
||||
return *users, nil
|
||||
}
|
||||
|
||||
func getUserInfo(db *gorm.DB, currentUsersId uint) (PrivateUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PrivateUser)
|
||||
res := db.Where("id = ?", currentUsersId).Table("users").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get info failed", "error", res.Error)
|
||||
return PrivateUser{}, errors.New("failed to find current user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
@@ -3,7 +3,9 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -24,16 +27,28 @@ type GormModel struct {
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
var AvailableAuthProviders = []string{}
|
||||
var (
|
||||
ServerInSetup = false
|
||||
logLevel = new(slog.LevelVar)
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("Watcharr Starting")
|
||||
|
||||
err := godotenv.Load()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to load vars from .env file:", err)
|
||||
// Do not fail if file does not exist
|
||||
if !os.IsNotExist(err) {
|
||||
log.Fatal("Failed to load vars from .env file:", err)
|
||||
}
|
||||
}
|
||||
ensureEnv()
|
||||
|
||||
multiw := setupLogging()
|
||||
slog.Info("Watcharr Starting")
|
||||
|
||||
if err = readConfig(); err != nil {
|
||||
log.Fatal("Failed to read server config!", err)
|
||||
}
|
||||
|
||||
setLoggingLevel()
|
||||
|
||||
// Ensure data dir exists
|
||||
err = ensureDirExists("./data")
|
||||
@@ -44,15 +59,16 @@ func main() {
|
||||
// Check if we want to be in DEV or PROD
|
||||
isProd := true
|
||||
if os.Getenv("MODE") == "DEV" {
|
||||
slog.Info("Starting in DEV mode")
|
||||
isProd = false
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{})
|
||||
if err != nil {
|
||||
panic("failed to connect to database")
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &Activity{})
|
||||
err = db.AutoMigrate(&User{}, &Content{}, &Watched{}, &Activity{}, &Token{})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
@@ -61,6 +77,7 @@ func main() {
|
||||
go runUI()
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
gin.DefaultWriter = multiw
|
||||
gine := gin.Default()
|
||||
gine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
@@ -82,22 +99,67 @@ func main() {
|
||||
})
|
||||
}
|
||||
br := newBaseRouter(db, gine.Group("/api"))
|
||||
// Only add setup routes if there are no users found in db.
|
||||
var userCount int64
|
||||
if uresp := db.Model(&User{}).Count(&userCount); uresp.Error == nil {
|
||||
if userCount != 0 {
|
||||
slog.Debug("registered users found.. skipped creating setup routes.")
|
||||
} else {
|
||||
slog.Info("No users found.. creating setup routes.")
|
||||
ServerInSetup = true
|
||||
br.addSetupRoutes()
|
||||
}
|
||||
} else {
|
||||
slog.Error("Failed to check if any users exist.. not registering setup routes", "error", uresp.Error)
|
||||
}
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addImportRoutes()
|
||||
br.addServerRoutes()
|
||||
br.rg.Static("/img", "./data/img")
|
||||
|
||||
go setupTasks(db)
|
||||
|
||||
gine.Run("0.0.0.0:3080")
|
||||
}
|
||||
|
||||
// Setup slog defaults
|
||||
func setupLogging() io.Writer {
|
||||
// logLevel = new(slog.LevelVar)
|
||||
multiw := io.MultiWriter(&lumberjack.Logger{
|
||||
Filename: "./data/watcharr.log",
|
||||
MaxSize: 1, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
Compress: false,
|
||||
}, os.Stdout)
|
||||
slog.SetDefault(slog.New(
|
||||
slog.NewTextHandler(multiw, &slog.HandlerOptions{Level: logLevel}),
|
||||
))
|
||||
return multiw
|
||||
}
|
||||
|
||||
// Set loggin level from config
|
||||
func setLoggingLevel() {
|
||||
if Config.DEBUG {
|
||||
logLevel.Set(slog.LevelDebug)
|
||||
} else {
|
||||
logLevel.Set(slog.LevelInfo)
|
||||
}
|
||||
slog.Info("Logging level set", "logging_level", logLevel)
|
||||
}
|
||||
|
||||
// Run UI server
|
||||
func runUI() {
|
||||
cmd := exec.Command("node", "ui/index.js")
|
||||
cmdReader, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
log.Fatal("UI ERR ", err)
|
||||
log.Fatal("UI ERR @ get stdout read pipe: ", err)
|
||||
}
|
||||
scanner := bufio.NewScanner(cmdReader)
|
||||
go func() {
|
||||
@@ -106,21 +168,10 @@ func runUI() {
|
||||
}
|
||||
}()
|
||||
if err := cmd.Start(); err != nil {
|
||||
log.Fatal("UI ERR ", err)
|
||||
log.Fatal("UI ERR @ command start: ", err)
|
||||
}
|
||||
if err := cmd.Wait(); err != nil {
|
||||
log.Fatal("UI ERR ", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all required environment variables are set.
|
||||
func ensureEnv() {
|
||||
if os.Getenv("JWT_SECRET") == "" {
|
||||
log.Fatal("JWT_SECRET env var missing!")
|
||||
}
|
||||
|
||||
if os.Getenv("JELLYFIN_HOST") != "" {
|
||||
AvailableAuthProviders = append(AvailableAuthProviders, "jellyfin")
|
||||
log.Fatal("UI ERR @ command wait: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
@@ -67,19 +68,45 @@ func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
return *watched
|
||||
}
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error) {
|
||||
println(ar.ContentType, ar.ContentID)
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
// First we need to make sure the users list is public
|
||||
user := new(User)
|
||||
// Figure we require knowlege of the users id and name to make it
|
||||
// harder to just type in random ids and see someones list.. dunno
|
||||
// if this is a thing we need but its here.. for now at least.
|
||||
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get user for getPublicWatched request")
|
||||
return []Watched{}, errors.New("failed to check privacy settings")
|
||||
}
|
||||
if user.Private != nil && *user.Private {
|
||||
slog.Error("getPublicWatched attempted to get a private list")
|
||||
return []Watched{}, errors.New("this watched list is private")
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
|
||||
var content Content
|
||||
db.Where("tmdb_id = ?", ar.ContentID).Find(&content)
|
||||
|
||||
// Create content if not found from our db
|
||||
if content == (Content{}) {
|
||||
println("Content not in db, fetching...")
|
||||
slog.Debug("Content not in db, fetching...")
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
fmt.Printf("addWatched tmdb api request failed: %+v", err)
|
||||
slog.Error("addWatched content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
@@ -106,7 +133,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
content := new(TMDBMovieDetails)
|
||||
err = json.Unmarshal([]byte(resp), &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal movie details:", err)
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
id = content.ID
|
||||
@@ -115,7 +142,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
title = content.Title
|
||||
releaseDate, err = time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
println("Failed to parse movie release date:", err)
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
@@ -129,7 +156,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
content := new(TMDBShowDetails)
|
||||
err = json.Unmarshal(resp, &content)
|
||||
if err != nil {
|
||||
println("Failed to unmarshal show details:", err)
|
||||
slog.Error("Failed to unmarshal show details", "error", err)
|
||||
return Watched{}, errors.New("failed to process show details response")
|
||||
}
|
||||
id = content.ID
|
||||
@@ -138,7 +165,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
title = content.Name
|
||||
releaseDate, err = time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
println("Failed to parse tv release date:", err)
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
popularity = content.Popularity
|
||||
voteAverage = content.VoteAverage
|
||||
@@ -151,9 +178,9 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
numberOfSeasons = content.NumberOfSeasons
|
||||
}
|
||||
// Save the content in our db
|
||||
println("id, etc:", id, title, overview, posterPath, "<-- end")
|
||||
slog.Info("Saving content to db", "id", id, "title", title)
|
||||
if id == 0 || title == "" {
|
||||
println("addWatched, returned content missing id or title!", id, title)
|
||||
slog.Error("addWatched, returned content missing id or title!", "id", id, "title", title)
|
||||
return Watched{}, errors.New("content response missing id or title")
|
||||
}
|
||||
content = Content{
|
||||
@@ -162,7 +189,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
Overview: overview,
|
||||
PosterPath: posterPath,
|
||||
Type: ar.ContentType,
|
||||
ReleaseDate: releaseDate,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: popularity,
|
||||
VoteAverage: voteAverage,
|
||||
VoteCount: voteCount,
|
||||
@@ -178,7 +205,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if !strings.Contains(res.Error.Error(), "UNIQUE") {
|
||||
println("Error creating content in database:", res.Error.Error())
|
||||
slog.Error("Error creating content in database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
@@ -186,7 +213,7 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
if res.RowsAffected > 0 {
|
||||
err := download("https://image.tmdb.org/t/p/w500"+posterPath, path.Join("./data/img", posterPath))
|
||||
if err != nil {
|
||||
println("Failed to download content image!", err.Error())
|
||||
slog.Error("Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,28 +236,29 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
println("Watched list item exists as soft deleted record.. attempting to restore")
|
||||
slog.Info("addWatched: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println("Error adding watched content to database:", res.Error.Error())
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
fmt.Printf("%+v\n", watched)
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var activity Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
println("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: ADDED_WATCHED})
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: ADDED_WATCHED, Data: string(activityJson)})
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Content = content
|
||||
@@ -239,11 +267,11 @@ func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest) (Watched, error)
|
||||
|
||||
// this method is too ugly to look at please make him look better, future irhm
|
||||
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (WatchedUpdateResponse, error) {
|
||||
println("UpdateWatched", ar.Rating, ar.Status)
|
||||
slog.Debug("UpdateWatched", "request_data", ar)
|
||||
upwat := Watched{}
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
|
||||
if res.Error != nil {
|
||||
println("Watched entry update failed:", id, res.Error.Error())
|
||||
slog.Error("Watched entry update failed:", "id", id, "error", res.Error.Error())
|
||||
return WatchedUpdateResponse{}, errors.New("failed to update watched entry")
|
||||
}
|
||||
originalThoughts := upwat.Thoughts
|
||||
@@ -280,14 +308,14 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
println("Removing watched item:", id, "for user", userId)
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
// Since other tables (eg activities) will link their rows to a watched_id, it's best to soft
|
||||
// delete, so if user restores watched item they still have activity for example (also so
|
||||
// someone else wont get other users activity if auto increment gives them the same watched id).
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Delete(&Watched{})
|
||||
if res.Error != nil {
|
||||
println("Removing watched entry failed:", id, res.Error.Error())
|
||||
slog.Error("Removing watched entry failed", "id", id, "error", res.Error.Error())
|
||||
return WatchedRemoveResponse{}, errors.New("failed to remove watched entry")
|
||||
}
|
||||
if res.RowsAffected <= 0 {
|
||||
@@ -298,7 +326,7 @@ func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, er
|
||||
}
|
||||
|
||||
func download(url string, outf string) (err error) {
|
||||
println("Attempting to download file from", url, "to", outf)
|
||||
slog.Debug("Attempting to download file", "url", url, "outf", outf)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
return "thoughts changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "thoughts removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "imported";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
@@ -106,7 +108,11 @@
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
@@ -147,7 +153,11 @@
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let value: boolean = false;
|
||||
export let toggled: (on: boolean) => void;
|
||||
export let disabled = false;
|
||||
|
||||
let actualDisabled = false;
|
||||
|
||||
$: {
|
||||
// In cases where we disable and enable the checkbox super fast
|
||||
// it ends up looking like a jittery mess. To circumvent this
|
||||
// issue, we add a small delay before undisabling.
|
||||
// (eg. fast internet and net request dis and re enables it quickly)
|
||||
if (disabled) {
|
||||
actualDisabled = true;
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
actualDisabled = disabled;
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
function checkboxChange(e: Event) {
|
||||
toggled((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="toggle-pill-color">
|
||||
<input
|
||||
bind:checked={value}
|
||||
disabled={actualDisabled}
|
||||
type="checkbox"
|
||||
id={name}
|
||||
on:change={checkboxChange}
|
||||
/>
|
||||
<label for={name}></label>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.toggle-pill-color {
|
||||
width: min-content;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
|
||||
&:checked + label {
|
||||
background: $success;
|
||||
|
||||
&::before {
|
||||
left: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + label {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 3em;
|
||||
height: 1.6em;
|
||||
border-radius: 1em;
|
||||
background: $error;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition:
|
||||
opacity 100ms ease-in-out,
|
||||
opacity 100ms ease-in-out;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
display: block;
|
||||
width: 1.2em;
|
||||
height: 1.2em;
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
position: absolute;
|
||||
left: 0.2em;
|
||||
top: 0.2em;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let options: string[];
|
||||
export let active: string | undefined;
|
||||
export let placeholder: string;
|
||||
export let blendIn: boolean = false;
|
||||
export let disabled = false;
|
||||
|
||||
let availableOptions = options.filter((o) => o !== active);
|
||||
let open = false;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
open ? "is-open" : "",
|
||||
!active ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : ""
|
||||
].join(" ")}
|
||||
>
|
||||
<button on:click={() => (open = !open)} {disabled}>
|
||||
{active ? active : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul>
|
||||
{#each availableOptions as o}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
on:click={() => {
|
||||
active = o;
|
||||
open = false;
|
||||
}}
|
||||
>
|
||||
{o}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
|
||||
&.placeholder-shown {
|
||||
& > button {
|
||||
color: #8e8e8e;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.blend-in {
|
||||
& > button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
ul {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& > button {
|
||||
border-color: $text-color;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
gap: 3px;
|
||||
text-transform: capitalize;
|
||||
|
||||
:global(svg) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ul {
|
||||
display: none;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
list-style: none;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
border: 2px solid $text-color;
|
||||
border-top: 0;
|
||||
border-bottom-left-radius: 5px;
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
z-index: 99;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
text-transform: capitalize;
|
||||
transition: background-color 100ms ease;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,19 +2,32 @@
|
||||
export let title: string | undefined;
|
||||
</script>
|
||||
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
<div>
|
||||
{#if title}
|
||||
<h2>{title}</h2>
|
||||
{/if}
|
||||
<ul>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
ul {
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
import type { Icon } from "@/types";
|
||||
|
||||
export let i: Icon;
|
||||
export let wh = 24;
|
||||
export let wh: number | string = 24;
|
||||
export let facing: "up" | "down" | "left" | "right" | undefined = undefined;
|
||||
</script>
|
||||
|
||||
{#if i === "check"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -84,7 +85,7 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -100,4 +101,143 @@
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "reel"}
|
||||
<svg
|
||||
id="reel"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 512 512"
|
||||
width={wh}
|
||||
height={wh}
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
fill="#fff"
|
||||
d="M426 138c-1-37-2-74-6-109-14-7-33-12-54-9-29 9-49 31-80 36-50 9-80-25-116-37-7-2-20 1-25-2l-3 2c-21 6-42 11-51 29-4 74-3 149-3 224 0 74-4 150 6 220 23 9 54 0 79-2 25-7 54-10 82-16 25-9 45-24 75-28 11 0 22 0 31 2 21 8 31 35 60 29l-1-111c-3-78 7-153 6-228Z"
|
||||
/>
|
||||
<path
|
||||
fill="#000"
|
||||
d="M158 133c19-2 55 3 75-2-19-8-49-1-72-4 0-16 5-39-3-51-5 13-3 42 0 57Zm-1-72c3-1 4-2 4-7-6-6-12 7-4 7Zm-8-34 19 1c36 15 61 41 114 38 33-5 54-22 80-34l-1 104c-70 4-143 1-213 4l1-111v-2Zm-15 21c2 4 1 11 0 15-8 1-20 3-28 2-2-4-1-11-1-15 8-2 21-4 29-2Zm268 6c3 3 1 10 2 15-7 3-21 2-29 1-2-4 0-11-1-16 7-4 21-2 28 0ZM133 80v20c-10 0-20-1-29-3l1-17c9-2 18 2 28 0Zm271 8c3 4 2 14 1 18-8-1-22 2-31-1l1-16c9-3 20 2 29-1Zm-300 28c9 2 20 1 31 1v20c-9 2-20-1-30-1 0-7-2-12-1-20m271 5 32 1c-1 6 1 14-1 19-11-1-22 2-32 0 1-7-1-16 1-20Zm-270 34c11-1 19 1 30 1v19c-9 3-21 0-30-1v-19Zm206 139c4 0 12 3 13-1-1-3-14-5-13 1Zm-144-1c5 7 23 1 27-5-9-2-20 2-27 5Zm-12-10c6-12 3-30 4-46 1-22 2-45-1-65l-3-1c-2 35-5 78 0 112Zm66-119 1 1c37 2 78 4 116 4 5 0 13 3 15-3-42-4-90-2-134-4l-1 1c1 0 1 2 3 1Zm-73-8c68 3 145 4 213 0-1 48-5 94-5 142-65 6-141 1-207 5l-2-145 1-2Zm259 6c1 5 0 12-1 17-12-1-24 4-33 0v-18c9-3 25-1 34 1Zm-2 35c0 6 1 14-1 19-11-1-24 0-31 1-3-4-3-16-1-20 7-3 23-1 33 0Zm-303 2c10-2 23 0 34 0l-2 20c-11 0-21-2-31-1 0-6 1-14-1-19Zm303 35-1 20c-9 2-22 1-32 1v-22c10-1 23 1 33 1Zm-302 0c11 2 26-2 33 3 0 7-2 12-1 20-11 0-22 0-32-2v-21Zm302 41c1 5 2 12 0 17-10 3-24 1-36 2v-22c12 1 28-2 36 3Zm-268 2-1 21c-10 2-24-2-34 0 1-7-1-16 1-21h34Zm268 37c2 5-1 11 0 18-10 2-25 0-35 2-2-5 0-14-2-19 9-3 27-2 37-1Zm-268 1v17c-9 3-25 2-35 1v-17c9-2 22-1 35-1Zm20 15c9 2 23 2 30-1-7-3-25-4-30 1Zm59-4c31 9 84 3 123 7 5 17 2 41 3 63-1 4-5 16 1 19 4-23 6-62 3-87-39-3-87-3-130-2Zm139-11c-1 39-4 83 0 121-23-6-40 5-61 7-45 4-97 2-143 6-2-43-1-87-2-130 68 2 137-4 206-4Zm49 34c0 6 2 11 1 17-7 4-21 1-32 2v-18c9-1 21-3 31-1m-270 0c1 8-1 13-1 21-10 0-23 1-30-3v-17c10-3 23 1 31-1m272 35v16c-9 2-21 0-32 0 0-5 1-11-1-16 9-1 24-2 33 0Zm-269 3v21c-10-3-28 1-35-4v-15c9-4 24-3 35-2Zm270 31v16c-8 3-24 2-32 0 0-6-1-11 1-16 9-2 23-2 31 0Zm-270 7 2 19c-10 3-25 0-35 2 0-6-2-15-1-21 11 0 24-2 34 0Zm119 36c-33 7-67 13-103 18 1-7-1-11-1-17 36 1 70 0 104-1Zm-119 1v20c-11 0-23 1-32-2v-17l32-1Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "compass"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M448 256a192 192 0 1 0-384 0 192 192 0 0 0 384 0z"
|
||||
/>
|
||||
<path
|
||||
d="m351 151-118 47a64 64 0 0 0-35 35l-47 118a8 8 0 0 0 10 10l118-47a64 64 0 0 0 35-35l47-118a8 8 0 0 0-10-10zm-95 129a24 24 0 1 1 24-24 24 24 0 0 1-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "document"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M416 221.25V416a48 48 0 0 1-48 48H144a48 48 0 0 1-48-48V96a48 48 0 0 1 48-48h98.75a32 32 0 0 1 22.62 9.37l141.26 141.26a32 32 0 0 1 9.37 22.62z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
d="M256 56v120a32 32 0 0 0 32 32h120m-232 80h160m-160 80h160"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "add"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M256 112v288M400 256H112"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "arrow"}
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M244 400L100 256l144-144M120 256h292"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "chevron"}
|
||||
<svg
|
||||
class={facing}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="48"
|
||||
d="M328 112L184 256l144 144"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "search"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M456.69 421.39L362.6 327.3a173.81 173.81 0 0034.84-104.58C397.44 126.38 319.06 48 222.72 48S48 126.38 48 222.72s78.38 174.72 174.72 174.72A173.81 173.81 0 00327.3 362.6l94.09 94.09a25 25 0 0035.3-35.3zM97.92 222.72a124.8 124.8 0 11124.8 124.8 124.95 124.95 0 01-124.8-124.8z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sort"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye-closed"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M432 448a15.92 15.92 0 01-11.31-4.69l-352-352a16 16 0 0122.62-22.62l352 352A16 16 0 01432 448zM248 315.85l-51.79-51.79a2 2 0 00-3.39 1.69 64.11 64.11 0 0053.49 53.49 2 2 0 001.69-3.39zM264 196.15L315.87 248a2 2 0 003.4-1.69 64.13 64.13 0 00-53.55-53.55 2 2 0 00-1.72 3.39z"
|
||||
/>
|
||||
<path
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
svg {
|
||||
transition: transform 100ms ease-in-out;
|
||||
|
||||
&.up {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
&.down {
|
||||
transform: rotate(270deg);
|
||||
}
|
||||
}
|
||||
|
||||
#reel {
|
||||
path:first-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
|
||||
path:last-of-type {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
<script lang="ts">
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let title: string;
|
||||
export let desc: string;
|
||||
export let onClose: (() => void) | undefined = undefined;
|
||||
</script>
|
||||
|
||||
<div class="backdrop"></div>
|
||||
<div class="modal">
|
||||
<div>
|
||||
{#if typeof onClose !== "undefined"}
|
||||
<button class="close" on:click={onClose}><Icon i="close" wh="20" /></button>
|
||||
{/if}
|
||||
<h3 class="norm">{title}</h3>
|
||||
<h5 class="norm">{desc}</h5>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
z-index: 9998;
|
||||
backdrop-filter: blur(2px) saturate(180%);
|
||||
background-color: color-mix(in srgb, black 85%, transparent);
|
||||
position: fixed;
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100dvw;
|
||||
height: 100dvh;
|
||||
top: 0;
|
||||
left: 0;
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
|
||||
& > div {
|
||||
position: relative;
|
||||
min-width: 300px;
|
||||
min-height: 300px;
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
background-color: $bg-color;
|
||||
border-radius: 10px;
|
||||
padding: 15px 20px;
|
||||
margin: 20px 100px;
|
||||
transition: margin 100ms ease;
|
||||
|
||||
button.close {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: max-content;
|
||||
padding: 3px 5px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 680px) {
|
||||
margin: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -65,7 +65,9 @@
|
||||
height: 100%;
|
||||
min-height: 256.367px;
|
||||
position: relative;
|
||||
transition: transform 150ms ease, outline 50ms ease;
|
||||
transition:
|
||||
transform 150ms ease,
|
||||
outline 50ms ease;
|
||||
cursor: pointer;
|
||||
|
||||
img {
|
||||
@@ -112,7 +114,11 @@
|
||||
|
||||
h2,
|
||||
h3 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
@@ -144,7 +150,7 @@
|
||||
&:not(:not(.no-zoom)) {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
outline: 3px solid black;
|
||||
outline: 3px solid $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,20 +9,28 @@
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
import tooltip from "./actions/tooltip";
|
||||
import { removeWatched, updateWatched } from "./util/api";
|
||||
import { notify } from "./util/notify";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let id: number | undefined = undefined; // Watched list id
|
||||
export let media: {
|
||||
poster_path?: string;
|
||||
title?: string;
|
||||
name?: string;
|
||||
overview?: string;
|
||||
id?: number;
|
||||
media_type?: MediaType;
|
||||
id: number; // tmdb id
|
||||
media_type: MediaType;
|
||||
release_date?: string;
|
||||
first_air_date?: string;
|
||||
};
|
||||
export let rating: number | undefined = undefined;
|
||||
export let status: WatchedStatus | undefined = undefined;
|
||||
export let onStatusChanged: (type: WatchedStatus) => void = () => {};
|
||||
export let onRatingChanged: (rating: number) => void = () => {};
|
||||
export let onDeleteClicked: () => void = () => {};
|
||||
export let small = false;
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
@@ -31,25 +39,54 @@
|
||||
// If statuses are shown
|
||||
let statusesShown = false;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
|
||||
const title = media.title || media.name;
|
||||
const poster = `https://image.tmdb.org/t/p/w500${media.poster_path}`;
|
||||
const link = media.id ? `/${media.media_type}/${media.id}` : undefined;
|
||||
const dateStr = media.release_date || media.first_air_date;
|
||||
const year = dateStr ? new Date(dateStr).getFullYear() : undefined;
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r == rating) return;
|
||||
onRatingChanged(r);
|
||||
updateWatched(media.id, media.media_type, undefined, r);
|
||||
ratingsShown = false;
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (type === "DELETE") {
|
||||
onDeleteClicked();
|
||||
if (!id) {
|
||||
notify({ text: "Content has no watched list id, can't delete.", type: "error" });
|
||||
return;
|
||||
}
|
||||
removeWatched(id);
|
||||
return;
|
||||
}
|
||||
onStatusChanged(type);
|
||||
updateWatched(media.id, media.media_type, type);
|
||||
}
|
||||
|
||||
function handleInnerKeyUp(e: KeyboardEvent) {
|
||||
console.log(e.target);
|
||||
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
if (link) {
|
||||
goto(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- HACK: disabled this issue for now, it should probably be fixed properly -->
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<li
|
||||
on:mouseenter={(e) => {
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
@@ -63,9 +100,9 @@
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={posterActive ? "active" : ""}
|
||||
class={`${posterActive ? "active " : ""}${disableInteraction ? "interaction-disabled" : ""}`}
|
||||
>
|
||||
<div class={`container${!poster ? " details-shown" : ""}`}>
|
||||
<div class={`container${!poster ? " details-shown" : ""}`} bind:this={containerEl}>
|
||||
{#if poster}
|
||||
<div class="img-loader" />
|
||||
<img
|
||||
@@ -82,92 +119,106 @@
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
return;
|
||||
}
|
||||
if (posterActive && link) goto(link);
|
||||
}}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
on:keyup={handleInnerKeyUp}
|
||||
id="ilikemoviessueme"
|
||||
class="inner"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<h2>
|
||||
{#if link}
|
||||
{#if typeof onClick === "undefined" && link}
|
||||
<a data-sveltekit-preload-data="tap" href={link}>
|
||||
{title}
|
||||
</a>
|
||||
{:else}
|
||||
{title}
|
||||
{/if}
|
||||
{#if year}
|
||||
<time>{year}</time>
|
||||
{/if}
|
||||
</h2>
|
||||
<span>{media.overview}</span>
|
||||
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="rating"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
ratingsShown = !ratingsShown;
|
||||
}}
|
||||
on:mouseleave={(ev) => {
|
||||
ratingsShown = false;
|
||||
ev.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
<span>*</span>
|
||||
<span>{rating ? rating : "Rate"}</span>
|
||||
{#if ratingsShown}
|
||||
<div>
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{rating === v ? ' active' : ''}"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
handleStarClick(v);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
statusesShown = !statusesShown;
|
||||
}}
|
||||
on:mouseleave={(ev) => {
|
||||
statusesShown = false;
|
||||
ev.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
{#if status}
|
||||
<Icon i={watchedStatuses[status]} />
|
||||
{:else}
|
||||
<span class="no-icon">+</span>
|
||||
{/if}
|
||||
{#if statusesShown}
|
||||
<div>
|
||||
{#each Object.entries(watchedStatuses) as [statusName, icon]}
|
||||
<button
|
||||
class="plain{status && status !== statusName ? ' not-active' : ''}"
|
||||
on:click={() => handleStatusClick(statusName)}
|
||||
use:tooltip={{ text: statusName }}
|
||||
>
|
||||
<Icon i={icon} />
|
||||
</button>
|
||||
{/each}
|
||||
{#if status}
|
||||
<button
|
||||
class="plain not-active"
|
||||
on:click={() => handleStatusClick("DELETE")}
|
||||
use:tooltip={{ text: "Delete" }}
|
||||
>
|
||||
<Icon i="trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if !hideButtons}
|
||||
<div class="buttons">
|
||||
<button
|
||||
class="rating"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
ratingsShown = !ratingsShown;
|
||||
}}
|
||||
on:mouseleave={(ev) => {
|
||||
ratingsShown = false;
|
||||
ev.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
<span>*</span>
|
||||
<span class={!rating && disableInteraction ? "unrated-text" : ""}>
|
||||
{rating ? rating : disableInteraction ? "Unrated" : "Rate"}
|
||||
</span>
|
||||
{#if ratingsShown}
|
||||
<div class="small-scrollbar">
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{rating === v ? ' active' : ''}"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
handleStarClick(v);
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="status"
|
||||
on:click={(ev) => {
|
||||
ev.stopPropagation();
|
||||
statusesShown = !statusesShown;
|
||||
}}
|
||||
on:mouseleave={(ev) => {
|
||||
statusesShown = false;
|
||||
ev.currentTarget.blur();
|
||||
}}
|
||||
>
|
||||
{#if status}
|
||||
<Icon i={watchedStatuses[status]} />
|
||||
{:else}
|
||||
<span class="no-icon">+</span>
|
||||
{/if}
|
||||
{#if statusesShown}
|
||||
<div class="small-scrollbar">
|
||||
{#each Object.entries(watchedStatuses) as [statusName, icon]}
|
||||
<button
|
||||
class="plain{status && status !== statusName ? ' not-active' : ''}"
|
||||
on:click={() => handleStatusClick(statusName)}
|
||||
use:tooltip={{ text: statusName }}
|
||||
>
|
||||
<Icon i={icon} />
|
||||
</button>
|
||||
{/each}
|
||||
{#if status}
|
||||
<button
|
||||
class="plain not-active"
|
||||
on:click={() => handleStatusClick("DELETE")}
|
||||
use:tooltip={{ text: "Delete" }}
|
||||
>
|
||||
<Icon i="trash" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@@ -177,6 +228,27 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
li.interaction-disabled {
|
||||
button {
|
||||
pointer-events: none;
|
||||
cursor: default;
|
||||
background-color: transparent;
|
||||
border: unset;
|
||||
fill: white;
|
||||
color: white;
|
||||
|
||||
span {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.unrated-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 15px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -210,7 +282,7 @@
|
||||
background-size: 400% 400%;
|
||||
animation: imgloader 4s ease infinite;
|
||||
|
||||
@-webkit-keyframes imgloader {
|
||||
@keyframes imgloader {
|
||||
0% {
|
||||
background-position: 50% 0%;
|
||||
}
|
||||
@@ -236,7 +308,11 @@
|
||||
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
h2 {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
color: white;
|
||||
word-wrap: break-word;
|
||||
@@ -244,6 +320,12 @@
|
||||
a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
time {
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
@@ -355,12 +437,21 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.small .inner span {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
&.small:hover,
|
||||
&.small:focus-within {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:global(.details-shown) {
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let type: "wrapped" | "vertical" = "wrapped";
|
||||
|
||||
let ulEl: HTMLUListElement;
|
||||
|
||||
onMount(() => {
|
||||
if (ulEl) {
|
||||
ulEl.classList.add(type);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<ul>
|
||||
<ul bind:this={ulEl}>
|
||||
<slot />
|
||||
</ul>
|
||||
</div>
|
||||
@@ -19,5 +33,13 @@
|
||||
flex-wrap: wrap;
|
||||
margin: 20px 10px;
|
||||
max-width: 1200px;
|
||||
|
||||
&:global(.vertical) {
|
||||
flex-wrap: nowrap;
|
||||
justify-content: unset;
|
||||
overflow-x: auto;
|
||||
padding: 15px 8px;
|
||||
margin: 5px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
import axios from "axios";
|
||||
import Spinner from "./Spinner.svelte";
|
||||
import Error from "./Error.svelte";
|
||||
import SeasonsListEpisode from "./SeasonsListEpisode.svelte";
|
||||
|
||||
export let tvId: number;
|
||||
export let seasons: TMDBShowSeason[];
|
||||
|
||||
let activeSeason = 1;
|
||||
let seasonDetailsReq = sdr(activeSeason);
|
||||
let seasonsEl: HTMLUListElement, episodesEl: HTMLDivElement;
|
||||
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
|
||||
|
||||
async function sdr(seasonNum: number) {
|
||||
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).data as TMDBSeasonDetails;
|
||||
@@ -18,18 +18,10 @@
|
||||
$: {
|
||||
seasonDetailsReq = sdr(activeSeason);
|
||||
}
|
||||
|
||||
// onMount(() => {
|
||||
// episodesEl.style.height = `${seasonsEl.clientHeight}px`;
|
||||
// seasonsEl.addEventListener("resize", () => {
|
||||
// console.log("resize");
|
||||
// episodesEl.style.height = `${seasonsEl.clientHeight}px`;
|
||||
// });
|
||||
// });
|
||||
</script>
|
||||
|
||||
<div class="ctr">
|
||||
<ul class="seasons" bind:this={seasonsEl}>
|
||||
<ul class="seasons">
|
||||
<!-- {#each seasons.sort((a, b) => Date.parse(b.air_date) - Date.parse(a.air_date)) as season} -->
|
||||
{#each seasons as season}
|
||||
<button
|
||||
@@ -41,42 +33,26 @@
|
||||
<h1>{season.name}</h1>
|
||||
{#if season.air_date}
|
||||
<h2>{new Date(Date.parse(season.air_date)).getFullYear()}</h2>
|
||||
{:else if season.season_number > 0}
|
||||
<h2>TBD</h2>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
<div class="last" />
|
||||
</ul>
|
||||
|
||||
<div class="episodes" bind:this={episodesEl}>
|
||||
<div class="episodes">
|
||||
{#await seasonDetailsReq}
|
||||
<Spinner />
|
||||
{:then season}
|
||||
{#if season}
|
||||
{#if season?.episodes?.length > 0}
|
||||
<ul>
|
||||
{#each season.episodes as ep}
|
||||
<li>
|
||||
{#if ep.still_path}
|
||||
<img
|
||||
src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`}
|
||||
alt=""
|
||||
/>
|
||||
{:else}
|
||||
<div class="no-still" />
|
||||
{/if}
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
{ep.name}
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
</li>
|
||||
<SeasonsListEpisode {ep} />
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<h3 class="norm">No episodes in this season yet!</h3>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error pretty="Failed to load season details!" error={err} />
|
||||
@@ -101,67 +77,6 @@
|
||||
list-style: none;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
|
||||
img,
|
||||
.no-still {
|
||||
width: 227px;
|
||||
min-width: 227px;
|
||||
height: 127px;
|
||||
min-height: 127px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
object-fit: fill;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
margin-top: 1.5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
li {
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ul.seasons {
|
||||
@@ -193,6 +108,7 @@
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
font-family: sans-serif;
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBSeasonDetailsEpisode } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { userSettings } from "@/store";
|
||||
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let ep: TMDBSeasonDetailsEpisode;
|
||||
|
||||
let isHidden = false;
|
||||
|
||||
$: {
|
||||
if (settings) isHidden = settings.hideSpoilers;
|
||||
}
|
||||
</script>
|
||||
|
||||
<li class={isHidden ? "dont-spoil" : ""}>
|
||||
{#if ep.still_path}
|
||||
<img src={`https://www.themoviedb.org/t/p/w227_and_h127_bestv2/${ep.still_path}`} alt="" />
|
||||
{:else}
|
||||
<div class="no-still" />
|
||||
{/if}
|
||||
<span>
|
||||
<b>{ep.episode_number}</b>
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
</span>
|
||||
<span
|
||||
class="rating"
|
||||
title={`TMDB Rating: ${ep.vote_average} out of 10 (based on ${ep.vote_count} votes)`}
|
||||
>
|
||||
<span>*</span>
|
||||
{Math.round(ep.vote_average * 10) / 10}
|
||||
</span>
|
||||
{#if isHidden}
|
||||
<button class="plain spoiler-text" on:click={() => (isHidden = false)}>
|
||||
<Icon i="eye-closed" wh={34} />
|
||||
<span>Click To Reveal</span>
|
||||
</button>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
<style lang="scss">
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
|
||||
img,
|
||||
.no-still {
|
||||
width: 227px;
|
||||
min-width: 227px;
|
||||
height: 127px;
|
||||
min-height: 127px;
|
||||
border-radius: 10px;
|
||||
background-color: rgb(0, 0, 0);
|
||||
object-fit: fill;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
width: 80%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
padding: 3px 5px;
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: center;
|
||||
font-size: 15px;
|
||||
color: $rating-color;
|
||||
font-weight: bolder;
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
font-family: "Rampart One";
|
||||
-webkit-text-stroke: 1px $rating-color;
|
||||
font-size: 25px;
|
||||
line-height: 0.7;
|
||||
margin-top: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
.spoiler-text {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-weight: bolder;
|
||||
font-size: 20px;
|
||||
fill: $text-color;
|
||||
opacity: 0;
|
||||
transition:
|
||||
visibility 150ms ease-in,
|
||||
opacity 150ms ease-in;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:active,
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.episode-name,
|
||||
img {
|
||||
transition: filter 150ms ease-out;
|
||||
}
|
||||
|
||||
&.dont-spoil {
|
||||
.episode-name {
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
img {
|
||||
filter: blur(6px);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 590px) {
|
||||
li {
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
export let style: string = "";
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 13px;
|
||||
border: 2px solid #000;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
import type { PublicUser } from "@/types";
|
||||
|
||||
export let users: PublicUser[];
|
||||
</script>
|
||||
|
||||
<ul>
|
||||
{#each users as user}
|
||||
<li title={user.username}><a href="/lists/{user.id}/{user.username}">{user.username}</a></li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 5px 20px;
|
||||
list-style: none;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
min-width: 50px;
|
||||
max-width: 200px;
|
||||
|
||||
a {
|
||||
flex-shrink: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
background-color: $accent-color;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
transition:
|
||||
background-color 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,103 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
import { activeFilters, activeSort } from "@/store";
|
||||
import type { Watched } from "@/types";
|
||||
|
||||
export let list: Watched[];
|
||||
export let isPublicList: boolean = false;
|
||||
|
||||
$: sort = $activeSort;
|
||||
$: filters = $activeFilters;
|
||||
$: watched = list
|
||||
.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
if (sort[1] === "UP") return a.content.title.localeCompare(b.content.title);
|
||||
else if (sort[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.filter((w) => {
|
||||
if (filters.status.length <= 0 && filters.type.length <= 0) return w;
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
return (
|
||||
filters.status.includes(w.status?.toLowerCase()) && filters.type.includes(w.content.type)
|
||||
);
|
||||
}
|
||||
if (filters.type.length > 0) {
|
||||
return filters.type.includes(w.content.type);
|
||||
}
|
||||
if (filters.status.length > 0) {
|
||||
return filters.status.includes(w.status?.toLowerCase());
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<PosterList>
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
<Poster
|
||||
id={w.id}
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type,
|
||||
release_date: w.content.release_date,
|
||||
first_air_date: w.content.first_air_date
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
disableInteraction={isPublicList}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-list">
|
||||
<Icon i="reel" wh={80} />
|
||||
{#if isPublicList}
|
||||
<h2 class="norm">This watched list is empty!</h2>
|
||||
<h4 class="norm">Come back later to see if they have added anything.</h4>
|
||||
{:else}
|
||||
<h2 class="norm">Your watched list is empty!</h2>
|
||||
<h4 class="norm">Try searching for something you would like to add.</h4>
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</PosterList>
|
||||
|
||||
<style lang="scss">
|
||||
.empty-list {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
|
||||
h2 {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,123 @@
|
||||
<!--
|
||||
Thank You Wikimedia
|
||||
https://commons.wikimedia.org/wiki/Category:SVG_logos_of_video_streaming_services
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
export let i: string;
|
||||
export let wh = 24;
|
||||
</script>
|
||||
|
||||
{#if i === "Apple TV Plus"}
|
||||
<a href="https://tv.apple.com/" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 45.046 17.091" width={wh} height={wh}>
|
||||
<path
|
||||
d="M9.436 2.742A3.857 3.857 0 0 0 10.316 0a3.769 3.769 0 0 0-2.51 1.311 3.622 3.622 0 0 0-.9 2.631 3.138 3.138 0 0 0 2.53-1.2m.82 1.381c-1.4-.081-2.58.8-3.25.8s-1.69-.756-2.79-.736a4.117 4.117 0 0 0-3.5 2.147c-1.5 2.6-.4 6.473 1.06 8.59.71 1.006 1.56 2.205 2.69 2.166s1.48-.7 2.77-.7 1.67.7 2.79.675 1.9-1.008 2.6-2.1a9.317 9.317 0 0 0 1.17-2.42 3.814 3.814 0 0 1-2.27-3.468 3.9 3.9 0 0 1 1.83-3.256 3.991 3.991 0 0 0-3.1-1.7m8.93-2.016V4.96h2.28v1.885h-2.28V13.6c0 1.008.45 1.522 1.45 1.522a7.482 7.482 0 0 0 .82-.06v1.9a7.823 7.823 0 0 1-1.35.1c-2.36 0-3.27-.917-3.27-3.216V6.89h-1.79V5h1.74V2.107Zm10.25 14.853h-2.5L22.736 5h2.49l2.95 9.608h.06L31.186 5h2.44Zm10.98 0h-2.16v-4.9h-4.64V9.9h4.63V5h2.16v4.9h4.64v2.158h-4.63Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Apple TV"}
|
||||
<a href="https://tv.apple.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
|
||||
<path
|
||||
d="M20.57 17.735h-1.815l-3.34-9.203h1.633l2.02 5.987c.075.231.273.9.586 2.012l.297-.997.33-1.006 2.094-6.004H24zm-5.344-.066a5.76 5.76 0 0 1-1.55.207c-1.23 0-1.84-.693-1.84-2.087V9.646h-1.063V8.532h1.121V7.081l1.476-.602v2.062h1.707v1.113H13.38v5.805c0 .446.074.75.214.932.14.182.396.264.75.264.207 0 .495-.041.883-.115zm-7.29-5.343c.017 1.764 1.55 2.358 1.567 2.366-.017.042-.248.842-.808 1.658-.487.71-.99 1.418-1.79 1.435-.783.016-1.03-.462-1.93-.462-.89 0-1.17.445-1.913.478-.758.025-1.344-.775-1.838-1.484-.998-1.451-1.765-4.098-.734-5.88.51-.89 1.426-1.451 2.416-1.46.75-.016 1.468.512 1.93.512.461 0 1.327-.627 2.234-.536.38.016 1.452.157 2.136 1.154-.058.033-1.278.743-1.27 2.219M6.468 7.988c.404-.495.685-1.18.61-1.864-.585.025-1.294.388-1.723.883-.38.437-.71 1.138-.619 1.806.652.05 1.328-.338 1.732-.825Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Amazon Prime Video"}
|
||||
<a href="https://www.amazon.com/gp/video/storefront/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
|
||||
<path
|
||||
d="M22.787 15.292c-.336-.43-2.222-.204-3.069-.103-.257.031-.296-.193-.065-.356 1.504-1.056 3.968-.75 4.255-.397.288.357-.076 2.827-1.485 4.007-.217.18-.423.084-.327-.155.317-.792 1.027-2.566.69-2.996m-1.093 1.248c-2.627 1.94-6.437 2.97-9.717 2.97-4.597 0-8.737-1.7-11.87-4.528-.246-.222-.026-.525.27-.353 3.38 1.967 7.559 3.151 11.876 3.151a23.63 23.63 0 0 0 9.06-1.854c.444-.188.816.293.381.614m.482-5.038c-.761 0-1.346-.209-1.755-.626-.409-.418-.613-1.017-.613-1.797 0-.799.209-1.425.627-1.88.418-.454.998-.682 1.741-.682.572 0 1.019.138 1.341.415.323.276.484.645.484 1.105 0 .461-.174.81-.52 1.046-.348.237-.86.355-1.535.355-.35 0-.654-.034-.912-.101.037.411.161.706.373.884.212.178.533.268.963.268.172 0 .34-.011.502-.033a6.208 6.208 0 0 0 .733-.157.304.304 0 0 1 .046-.004c.104 0 .156.07.156.212v.424c0 .098-.013.167-.04.207a.341.341 0 0 1-.162.106 3.954 3.954 0 0 1-1.429.258m-.304-2.893c.314 0 .541-.048.682-.143.142-.095.212-.241.212-.438 0-.387-.23-.58-.69-.58-.59 0-.931.362-1.024 1.087.246.05.52.074.82.074m-9.84 2.755c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.074.249c.325-.215.619-.367.88-.456.26-.09.53-.134.806-.134.553 0 .943.197 1.17.59a3.77 3.77 0 0 1 .885-.452c.276-.092.562-.138.857-.138.43 0 .763.12 1 .36.236.239.354.574.354 1.004v3.253c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.885c-.08 0-.138-.018-.175-.055-.037-.037-.055-.096-.055-.175V8.176c0-.418-.188-.627-.562-.627-.332 0-.667.08-1.005.24v3.345c0 .08-.017.138-.05.175-.034.037-.094.055-.18.055h-.884c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V8.176c0-.418-.187-.627-.562-.627-.344 0-.682.083-1.013.249v3.336c0 .08-.017.138-.051.175-.034.037-.094.055-.18.055zM9.987 5.927c-.234 0-.42-.064-.562-.193-.142-.129-.212-.304-.212-.525 0-.221.07-.397.212-.526.141-.129.328-.193.562-.193.233 0 .42.064.562.193a.676.676 0 0 1 .212.526c0 .22-.07.396-.212.525-.141.129-.329.193-.562.193m-.443 5.437c-.08 0-.138-.019-.175-.055-.037-.037-.055-.096-.055-.176V6.886c0-.086.018-.146.055-.18.037-.034.096-.05.175-.05h.885c.086 0 .146.016.18.05s.05.094.05.18v4.247c0 .08-.017.139-.05.176-.034.036-.094.055-.18.055zm-3.681 0c-.08 0-.139-.018-.176-.055-.036-.037-.055-.096-.055-.175V6.886c0-.086.019-.146.055-.18.037-.034.096-.05.176-.05h.663c.141 0 .227.067.258.202l.12.497c.245-.27.477-.462.695-.575.219-.114.45-.17.696-.17h.13c.085 0 .147.016.183.05.037.034.056.094.056.18v.773c0 .08-.017.139-.051.176-.034.036-.094.055-.18.055a1.93 1.93 0 0 1-.166-.01 2.968 2.968 0 0 0-.258-.009c-.14 0-.313.02-.516.06-.202.04-.374.091-.515.152v3.097c0 .08-.018.138-.051.175-.034.037-.094.055-.18.055zM.344 13.262c-.08 0-.138-.017-.175-.05-.037-.034-.055-.095-.055-.18V6.886c0-.086.018-.146.055-.18.037-.034.095-.05.175-.05h.664c.14 0 .227.067.258.202l.064.24a2.03 2.03 0 0 1 .668-.424 2.13 2.13 0 0 1 .797-.157c.596 0 1.067.218 1.414.654.348.437.521 1.026.521 1.77 0 .51-.086.955-.258 1.336-.172.38-.405.674-.7.88a1.727 1.727 0 0 1-1.014.308c-.252 0-.491-.04-.719-.12a1.74 1.74 0 0 1-.58-.331v2.018c0 .085-.017.146-.05.18-.034.033-.095.05-.18.05zm2.018-2.81c.344 0 .597-.117.76-.35.163-.234.245-.603.245-1.106 0-.51-.08-.882-.24-1.115-.16-.234-.415-.35-.765-.35-.32 0-.62.083-.903.248v2.424c.27.166.571.249.903.249Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Disney Plus"}
|
||||
<a href="https://disneyplus.com/" target="_blank">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1041 565" width={wh} height={wh}
|
||||
><path
|
||||
d="M736 366c-15 3-52 5-52 5l-5 15s19-2 32 0c0 0 5-1 5 5v11s0 3-5 4l-41 2-6 20s-2 4 3 3 42-8 47-7 11 8 9 14c-2 8-39 32-62 30 0 0-12 1-22-15-9-16 4-45 4-45s-6-13-2-18c0 0 3-2 10-3l9-19s-10 1-16-6c-6-8-7-11-2-13s48-10 78-9c0 0 10-1 19 17 0 0 4 7-3 9Zm-112 72c-4 9-14 19-27 13-12-6-32-46-32-46s-7-15-9-15c0 0-1-3-2 13-1 17 0 49-7 54-6 5-13 3-17-3s-5-20-3-44 8-50 15-58 13-2 15 0c0 0 10 9 26 34l2 5 16 24h2c2 0 1-8 1-8s-3-26-16-71c0 0-2-5 0-11 1-5 6-2 6-2s21 10 30 43c10 33 4 63 0 72Zm-100-85c-2 3-3 8-12 10 0 0-82 5-86 11 0 0-3 3 2 4 4 1 23 4 32 4s42 1 53 15c0 0 7 7 7 23s-3 21-9 27c-7 6-63 31-99-8 0 0-16-18 6-32 0 0 16-10 57 1 0 0 12 5 12 9-1 5-10 10-24 10s-23-7-21-6c1 1-15-8-20-2-5 5-4 9 1 12 13 7 61 5 75-11 0 0 6-7-3-12-8-5-33-8-43-9-9 0-44 0-49-9 0 0-5-6 1-24s46-25 63-27c0 0 48-1 57 8v6ZM388 461c-6 4-19 2-22-2-4-5-5-22-4-49s1-60 7-66c6-5 10 0 12 3 3 4 6 8 7 16l2 54s3 40-2 44Zm12-144c-17 6-28 4-38 0-5 7-7 9-10 10-5 0-9-7-10-10-1-2-3-5-1-12-9-9-10-21-8-28 2-9 18-44 68-48 0 0 24-1 28 11h1s23 1 22 21c0 21-26 47-52 56Zm-46-46c-5 8-5 13-3 16 6-9 16-22 32-33-12 1-22 6-29 17Zm68-14c-15 3-39 24-51 41 18 3 49 2 62-26 0 0 7-18-11-15Zm421 162c-9 16-35 50-70 42-12 28-21 56-27 98 0 0-1 8-8 5-7-2-18-13-20-29-2-20 7-55 25-94-5-9-9-22-6-40 0 0 5-33 38-63 0 0 4-3 7-2 2 1 1 12-1 17l-17 31s-9 17-7 31c18-27 58-81 82-64 9 6 12 19 12 33 0 12-3 25-8 35Zm-7-43s-2-11-12 1c-9 10-25 29-38 54 13-2 27-9 31-13 6-6 21-21 19-42Zm-486 14c-2 24-11 64-77 85-43 13-84 6-107 1 0 9-1 12-3 14s-16 10-24-2c-3-5-5-15-6-24-50-23-74-57-74-58-2-1-13-13-2-28 11-13 47-27 78-32 1-27 5-48 8-57 5-11 11-1 16 6 4 6 7 29 7 48 21-1 33 1 56 5 30 6 50 21 49 38-2 18-17 25-23 25-7 1-17-4-17-4-6-3 0-6 8-9 9-5 7-9 7-9-3-10-43-16-82-16l2 78c27 5 47 4 47 4s100-3 103-66c3-64-99-125-175-145-76-19-118-6-122-4s0 3 0 3 4 0 11 3c7 2 2 6 2 6-13 4-28 2-31-4-2-6 2-11 8-19 5-8 11-8 11-8 93-32 207 26 207 26 107 55 125 118 123 143Zm-282-4c-11 5-3 13-3 13 20 21 44 35 67 43 3-37 3-50 3-68-36 2-57 8-67 12ZM1041 379v13c0 3-2 5-5 5h-63v9c0 19-1 35-3 53 0 3-2 5-5 5h-13a5 5 0 0 1-5-5c2-18 3-34 3-53l-1-9h-62c-3 0-5-2-5-5v-13c0-3 2-6 5-6h62a461 461 0 0 0-8-66l4-2h14c3 0 5 2 5 4 4 22 7 43 8 64h64c3 0 5 3 5 6Z"
|
||||
/>
|
||||
<path d="M199 204h1-1Zm0 0h1-1Z" fill="none" />
|
||||
<defs>
|
||||
<radialGradient
|
||||
id="a"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
cx="942.5"
|
||||
cy="279.9"
|
||||
r="760.1"
|
||||
fx="942.5"
|
||||
fy="279.9"
|
||||
>
|
||||
<stop offset="0" stop-color="#021192" /><stop offset="0" stop-color="#021096" /><stop
|
||||
offset=".1"
|
||||
stop-color="#010cb4"
|
||||
/>
|
||||
<stop offset=".1" stop-color="#0008ce" /><stop offset=".1" stop-color="#0006d7" /><stop
|
||||
offset=".1"
|
||||
stop-color="#0004e1"
|
||||
/>
|
||||
<stop offset=".2" stop-color="#0001fa" /><stop offset=".2" stop-color="#0000fe" /><stop
|
||||
offset=".2"
|
||||
stop-color="#0f1eff"
|
||||
/>
|
||||
<stop offset="1" stop-color="#0ff" stop-opacity="0" /></radialGradient
|
||||
>
|
||||
</defs>
|
||||
<path
|
||||
d="M955 274a439 439 0 0 0-772-74c0 2 2 3 3 4l11 4h2c3 0 5-1 6-3A419 419 0 0 1 549 23a414 414 0 0 1 383 254c1 2 3 3 5 3h14c1 0 3 0 4-2v-4Zm-756-70h1-1Zm735 75h1-1Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Netflix"}
|
||||
<a href="https://netflix.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width="30">
|
||||
<path
|
||||
d="M5.398 0v.006c3.028 8.556 5.37 15.175 8.348 23.596 2.344.058 4.85.398 4.854.398-2.8-7.924-5.923-16.747-8.487-24zm8.489 0v9.63L18.6 22.951c-.043-7.86-.004-15.913.002-22.95zM5.398 1.05V24c1.873-.225 2.81-.312 4.715-.398v-9.22z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Sky Go"}
|
||||
<a href="https://go.sky.com/" target="_blank">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" width={wh} height={wh}>
|
||||
<path
|
||||
d="M7.387 13.656c0 1.423-.933 2.454-2.823 2.675-1.35.147-3.337-.025-4.294-.148-.025-.147-.074-.343-.074-.49 0-1.252.663-1.522 1.3-1.522.664 0 1.694.123 2.455.123.834 0 1.104-.295 1.104-.565 0-.368-.343-.515-1.006-.638l-1.767-.343C.785 12.453 0 11.423 0 10.343c0-1.325.933-2.454 2.798-2.65 1.398-.148 3.116.024 4.049.122.024.172.049.32.049.491 0 1.252-.663 1.522-1.276 1.522-.491 0-1.227-.099-2.086-.099-.884 0-1.227.246-1.227.54 0 .32.343.442.883.54l1.718.32c1.742.294 2.479 1.3 2.479 2.527m3.092 1.521c0 .761-.295 1.203-1.792 1.203-.196 0-.368-.025-.54-.05V6.22c0-.76.27-1.57 1.767-1.57.196 0 .393.024.565.049zm6.085 3.927c.197.098.59.22 1.105.245.859.025 1.325-.319 1.693-1.08L24 7.913a2.5 2.5 0 0 0-.957-.22c-.589 0-1.399.122-1.914 1.325l-1.497 3.534-2.945-4.81c-.196-.05-.662-.148-1.006-.148-1.03 0-1.62.393-2.233 1.031l-2.871 3.141 2.306 3.632c.418.663.982 1.006 1.89 1.006.589 0 1.104-.147 1.325-.245l-2.773-4.196 1.963-2.086 3.24 5.08Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Paramount Plus"}
|
||||
<a href="https://paramountplus.com" target="_blank">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="100"
|
||||
viewBox="-161.6 95.4 1000 230.4"
|
||||
xml:space="preserve"
|
||||
>
|
||||
<path
|
||||
d="M94 154c0-40-42-59-92-59-53 0-102 24-120 61a69 69 0 0 0-8 31 47 47 0 0 0 8 27c7 11 21 19 41 19 25 0 45-14 45-38 0 0 0-6-8-6-6 0-7 4-7 6 1 19-10 34-30 34-22 0-31-19-31-39 0-41 31-68 63-80a128 128 0 0 1 47-8c36 0 66 14 66 51 0 32-26 58-58 60l1-4c7-23 15-48 28-66a71 71 0 0 1 3-5l-2-2a76 76 0 0 0-8 5c-60 47-62 179-142 179a62 62 0 0 1-8 0c-17-3-26-14-26-30l1-10a8 8 0 0 0-7-8h-1c-7 0-10 6-11 14 0 23 17 37 44 39l9 1c62 0 101-48 117-105a138 138 0 0 0 24-5c29-8 62-28 62-62zm596 14h-22l-5 11-9 20h-10l-3 6h11l-13 29c-9 20-24 39-32 39-2 0-3 0-3-2l3-9 10-21c4-9 12-24 12-31s-5-13-14-13c-10 0-19 6-28 15l6-13h-21l-17 38c-7 14-20 36-29 36-2 0-3-1-3-2a16 16 0 0 1 2-5l11-26 18-42h-22l-18 41c-6 13-20 34-29 34a3 3 0 0 1-3-2v-1l3-9 9-21 20-42h-22l-5 10c-3 6-9 14-18 14-4 0-6-2-7-3-1-14-10-22-23-22-19 0-32 11-40 24a123 123 0 0 0-13 29c-8 13-17 23-23 23-2 0-3-1-3-3l3-9 15-31c4-9 6-15 6-22 0-6-4-11-12-11-10 0-22 6-31 16l1-5c0-7-4-11-11-11-10 0-20 6-29 16l6-14h-20l-17 38c-8 18-21 36-29 36-1 0-2-1-2-3l5-15 22-52c2-3-9-6-22-6-15 0-29 7-40 16-7 6-13 9-16 9a2 2 0 0 1-2-1c0-3 8-12 8-19 0-3-2-5-6-5-8 0-18 8-25 16l6-14h-19l-18 38c-8 18-21 37-28 37-2 0-3-1-3-3 0-3 4-12 6-16l22-52c2-3-9-6-23-6-20 0-40 14-50 28a83 83 0 0 0-20 45c0 10 5 17 16 17 13 0 23-10 28-17a36 36 0 0 0-1 4c0 8 3 13 12 13 8 0 19-7 27-17l-7 15h22l21-47c7-16 15-24 17-24a1 1 0 0 1 1 1c0 1-3 6-3 10s2 7 8 7c4 0 10-2 14-5a79 79 0 0 0-18 43c0 10 5 17 16 17 12 0 23-11 29-18a45 45 0 0 0-1 5c0 7 4 13 13 13s16-6 25-17l-7 15h22l21-45c8-20 20-31 26-31a3 3 0 0 1 3 3 20 20 0 0 1-2 7l-29 67h23l21-47c8-20 18-30 25-30 3 0 4 1 4 4a20 20 0 0 1-3 7l-19 42a39 39 0 0 0-4 13c0 7 4 12 14 12 13 0 22-9 33-24v3c1 12 8 22 25 22 20 0 36-14 46-36a78 78 0 0 0 7-25 14 14 0 0 0 7 1 19 19 0 0 0 10-2l-3 7-12 26a41 41 0 0 0-4 15c0 8 4 14 13 14s21-9 30-21a34 34 0 0 0 0 8c0 6 2 13 11 13 10 0 19-8 27-18l-7 16h22l18-41c10-23 20-36 29-36a3 3 0 0 1 4 2v1c0 4-6 14-11 25l-10 22a43 43 0 0 0-4 16c0 7 4 12 12 12 13 0 26-13 33-22a46 46 0 0 0-2 14c0 13 7 19 18 19a34 34 0 0 0 20-6c9-6 15-15 21-23l-4-4c-5 8-11 15-17 19a19 19 0 0 1-11 5c-5 0-8-3-8-9s3-13 7-24l12-27 10-24h15l3-6h-15l14-31zM64 274c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 23-21 38-30 38zm137 0c-3 0-5-1-5-5 0-11 9-31 20-46 8-11 18-18 29-18l-14 31c-10 24-21 38-30 38zm244-61c0 12-11 41-23 58-5 7-9 9-13 9-5 0-7-4-6-11 1-10 10-36 23-53 5-8 9-11 14-11 4 0 5 3 5 8zm343-14 20-47h-24l-20 47h-51l-9 19h51l-20 47h24l20-47h51l8-19h-50z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<span>{i}</span>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
a {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: white;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import type { TMDBContentWatchProviders } from "@/types";
|
||||
import ProviderIcon from "./ProviderIcon.svelte";
|
||||
|
||||
export let providers: TMDBContentWatchProviders;
|
||||
</script>
|
||||
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
<div class="streaming-providers">
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.streaming-providers {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
padding-top: 10px;
|
||||
|
||||
& span {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { ContentType, TMDBMovieSimilar, TMDBShowSimilar } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import Poster from "../Poster.svelte";
|
||||
import { watchedList } from "@/store";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
|
||||
$: wList = $watchedList;
|
||||
|
||||
export let type: ContentType;
|
||||
export let similar: TMDBShowSimilar | TMDBMovieSimilar;
|
||||
</script>
|
||||
|
||||
<HorizontalList title="Similar">
|
||||
{#each similar.results as content}
|
||||
<Poster
|
||||
media={{ ...content, media_type: type }}
|
||||
{...getWatchedDependedProps(content.id, type, wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
@@ -1,9 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let embed: string;
|
||||
export let closed: () => void;
|
||||
|
||||
let modalDiv: HTMLDivElement;
|
||||
|
||||
onMount(() => {
|
||||
// For better experience on keyboard.
|
||||
// If modal shown by btn click, the btn will still be
|
||||
// focused, so clicking `esc` wont trigger until tab
|
||||
// is clicked first... which I count as not intuitive.
|
||||
if (modalDiv) {
|
||||
modalDiv.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
closed();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="modal" on:click={closed} on:keydown={closed}>
|
||||
<div
|
||||
bind:this={modalDiv}
|
||||
class="modal"
|
||||
on:click={closed}
|
||||
on:keydown={handleKeyDown}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
{#if embed}
|
||||
<div class="wrapper">
|
||||
<iframe title="Video Embed" src={embed} frameborder="0" width="100%" height="100%" />
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { watchedList } from "@/store";
|
||||
import type {
|
||||
MediaType,
|
||||
Watched,
|
||||
WatchedAddRequest,
|
||||
WatchedStatus,
|
||||
WatchedUpdateRequest
|
||||
import { userInfo, userSettings, watchedList } from "@/store";
|
||||
import {
|
||||
UserType,
|
||||
type JellyfinFoundContent,
|
||||
type MediaType,
|
||||
type Watched,
|
||||
type WatchedAddRequest,
|
||||
type WatchedStatus,
|
||||
type WatchedUpdateRequest,
|
||||
type WatchedUpdateResponse,
|
||||
type UserSettings
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { get } from "svelte/store";
|
||||
@@ -34,6 +38,7 @@ export function updateWatched(
|
||||
(w) => w.content.tmdbId === contentId && w.content.type === contentType
|
||||
);
|
||||
if (wEntry?.id) {
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
if (!status && !rating && typeof thoughts === "undefined") return;
|
||||
const obj = {} as WatchedUpdateRequest;
|
||||
if (status) obj.status = status;
|
||||
@@ -41,7 +46,7 @@ export function updateWatched(
|
||||
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
|
||||
if (thoughts === "") obj.removeThoughts = true;
|
||||
axios
|
||||
.put(`/watched/${wEntry.id}`, obj)
|
||||
.put<WatchedUpdateResponse>(`/watched/${wEntry.id}`, obj)
|
||||
.then((resp) => {
|
||||
if (status) wEntry.status = status;
|
||||
if (rating) wEntry.rating = rating;
|
||||
@@ -52,17 +57,22 @@ export function updateWatched(
|
||||
} else {
|
||||
wEntry.activity = [resp.data.newActivity];
|
||||
}
|
||||
// We want to update the updatedAt field too (so
|
||||
// change is reflected when filtering modified at)
|
||||
// We can piggy back from this data for now.
|
||||
wEntry.updatedAt = resp.data.newActivity.createdAt;
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ text: `Saved!`, type: "success" });
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ text: "Failed To Update!", type: "error" });
|
||||
notify({ id: nid, text: "Failed To Update!", type: "error" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Add new watched item
|
||||
const nid = notify({ text: `Adding`, type: "loading" });
|
||||
axios
|
||||
.post("/watched", {
|
||||
contentId,
|
||||
@@ -74,11 +84,11 @@ export function updateWatched(
|
||||
console.log("Added watched:", resp.data);
|
||||
wList.push(resp.data as Watched);
|
||||
watchedList.update(() => wList);
|
||||
notify({ text: `Added!`, type: "success" });
|
||||
notify({ id: nid, text: `Added!`, type: "success" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ text: "Failed To Add!", type: "error" });
|
||||
notify({ id: nid, text: "Failed To Add!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -87,10 +97,12 @@ export function updateWatched(
|
||||
* @param id Watched Entry ID
|
||||
*/
|
||||
export function removeWatched(id: number) {
|
||||
const nid = notify({ text: `Removing`, type: "loading" });
|
||||
const wList = get(watchedList);
|
||||
const wEntry = wList.find((w) => w.id === id);
|
||||
if (!wEntry) {
|
||||
console.log("Watched entry does not exist!");
|
||||
notify({ text: "Item Doesn't Exist On Watched List!", type: "error" });
|
||||
return;
|
||||
}
|
||||
axios
|
||||
@@ -99,10 +111,61 @@ export function removeWatched(id: number) {
|
||||
console.log("Removed watched:", resp.data);
|
||||
const newList = wList.filter((w) => w.id !== id);
|
||||
watchedList.update(() => newList);
|
||||
notify({ id: nid, text: "Removed!", type: "error" });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
notify({ text: "Failed To Remove!", type: "error" });
|
||||
notify({ id: nid, text: "Failed To Remove!", type: "error" });
|
||||
});
|
||||
}
|
||||
|
||||
export async function contentExistsOnJellyfin(
|
||||
type: MediaType,
|
||||
name: string,
|
||||
tmdbId: number
|
||||
): Promise<JellyfinFoundContent | undefined> {
|
||||
try {
|
||||
const user = get(userInfo);
|
||||
if (Number(user?.type) == UserType.Jellyfin) {
|
||||
const resp = await axios.get(`/jellyfin/${type}/${name}/${tmdbId}`);
|
||||
console.log("contentExistsOnJellyfin response:", resp.data);
|
||||
return resp.data as JellyfinFoundContent;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
// notify({ text: "Failed To Remove!", type: "error" });
|
||||
}
|
||||
}
|
||||
|
||||
export function updateUserSetting<K extends keyof UserSettings>(
|
||||
name: K,
|
||||
value: UserSettings[K],
|
||||
done?: () => void
|
||||
) {
|
||||
console.log("Updating user setting", name, "to", value);
|
||||
const uSettings = get(userSettings);
|
||||
if (!uSettings) {
|
||||
console.log("updateUserSetting: userSettings not set..");
|
||||
return;
|
||||
}
|
||||
const originalValue = uSettings[name];
|
||||
const nid = notify({ type: "loading", text: "Updating" });
|
||||
axios
|
||||
.post("/user/update", { [name]: value })
|
||||
.then((r) => {
|
||||
if (r.status === 200) {
|
||||
uSettings[name] = value;
|
||||
userSettings.update((u) => (u = uSettings));
|
||||
notify({ id: nid, type: "success", text: "Updated" });
|
||||
if (typeof done !== "undefined") done();
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to update user setting", err);
|
||||
notify({ id: nid, type: "error", text: "Couldn't Update" });
|
||||
uSettings[name] = originalValue;
|
||||
userSettings.update((u) => (u = uSettings));
|
||||
if (typeof done !== "undefined") done();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { appTheme } from "@/store";
|
||||
import type {
|
||||
Icon,
|
||||
MediaType,
|
||||
TMDBContentCreditsCrew,
|
||||
Theme,
|
||||
Watched,
|
||||
WatchedStatus
|
||||
import {
|
||||
UserPermission,
|
||||
type Icon,
|
||||
type MediaType,
|
||||
type TMDBContentCreditsCrew,
|
||||
type Theme,
|
||||
type TokenClaims,
|
||||
type Watched,
|
||||
type WatchedStatus
|
||||
} from "@/types";
|
||||
|
||||
export const watchedStatuses: {
|
||||
@@ -59,6 +61,7 @@ export function getWatchedDependedProps(wid: number, wtype: MediaType, list: Wat
|
||||
if (!wel) return {};
|
||||
console.log(wid, wtype, wel?.content.title, wel?.status, wel?.rating);
|
||||
return {
|
||||
id: wel.id,
|
||||
status: wel.status,
|
||||
rating: wel.rating
|
||||
};
|
||||
@@ -159,3 +162,29 @@ export function toggleTheme(theme: Theme) {
|
||||
appTheme.update((t) => (t = "light"));
|
||||
}
|
||||
}
|
||||
|
||||
export function parseTokenPayload(): TokenClaims | undefined {
|
||||
try {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) return;
|
||||
return JSON.parse(atob(token.split(".")[1])) as TokenClaims;
|
||||
} catch (err) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sleep(ms: number) {
|
||||
return new Promise<void>((r) =>
|
||||
setTimeout(() => {
|
||||
r();
|
||||
}, ms)
|
||||
);
|
||||
}
|
||||
|
||||
export function userHasPermission(perms: UserPermission, reqPerm: UserPermission): boolean {
|
||||
// Admins have permission for everything.
|
||||
if (perms & UserPermission.PERM_ADMIN) {
|
||||
return true;
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ import { notifications } from "@/store";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export interface Notification {
|
||||
/**
|
||||
* Notification ID.
|
||||
* Used to reference an exiting notification.
|
||||
*/
|
||||
id?: number;
|
||||
|
||||
/**
|
||||
* Text shown in popup;
|
||||
*/
|
||||
@@ -9,8 +15,10 @@ export interface Notification {
|
||||
|
||||
/**
|
||||
* Type of notification, controls the style.
|
||||
* Loading notifs never hide, so after request completion
|
||||
* or failure, the notif must be updated or `unNotify`ed.
|
||||
*/
|
||||
type?: "error" | "success";
|
||||
type?: "error" | "success" | "loading";
|
||||
|
||||
/**
|
||||
* How long in milliseconds the popup will stay shown for.
|
||||
@@ -20,11 +28,22 @@ export interface Notification {
|
||||
|
||||
export function notify(n: Notification) {
|
||||
const notifs = get(notifications);
|
||||
const id = Math.random();
|
||||
notifs.push({ id, ...n });
|
||||
notifications.update(() => notifs);
|
||||
setTimeout(() => unNotify(id), n.time ?? 2500);
|
||||
return id;
|
||||
if (n.id) {
|
||||
const notif = notifs.find((not) => not.id === n.id);
|
||||
if (notif) {
|
||||
notif.type = n.type;
|
||||
notif.text = n.text;
|
||||
notifications.update(() => notifs);
|
||||
} else {
|
||||
console.error("Can't update notif that doesnt exist", n);
|
||||
}
|
||||
} else {
|
||||
n.id = Math.random();
|
||||
notifs.push({ ...n });
|
||||
notifications.update(() => notifs);
|
||||
}
|
||||
if (n.type !== "loading") setTimeout(() => unNotify(n.id!), n.time ?? 2500);
|
||||
return n.id;
|
||||
}
|
||||
|
||||
export function unNotify(id: number) {
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "Shrikhand";
|
||||
src: local("Shrikhand Regular"), local("Shrikhand-Regular"),
|
||||
src:
|
||||
local("Shrikhand Regular"),
|
||||
local("Shrikhand-Regular"),
|
||||
url("/Shrikhand-Regular.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
@@ -11,7 +13,9 @@
|
||||
|
||||
@font-face {
|
||||
font-family: "Rampart One";
|
||||
src: local("Rampart One Regular"), local("RampartOne-Regular"),
|
||||
src:
|
||||
local("Rampart One Regular"),
|
||||
local("RampartOne-Regular"),
|
||||
url("/RampartOne-Regular.woff2") format("woff2");
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
@@ -25,13 +29,25 @@
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
font-family: sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
background-color: $bg-color;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
:global(h1:not(.norm), h2:not(.norm), h3:not(.norm), h4:not(.norm), h5:not(.norm)) {
|
||||
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
"Shrikhand",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
|
||||
:global(.sm-caps) {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
:global(a) {
|
||||
@@ -49,8 +65,8 @@
|
||||
:global(textarea) {
|
||||
padding: 5px;
|
||||
max-width: 100%;
|
||||
background-color: transparent;
|
||||
color: $text-color;
|
||||
background-color: transparent;
|
||||
border-color: $text-color;
|
||||
}
|
||||
|
||||
@@ -58,7 +74,8 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(button:not(.plain)) {
|
||||
:global(button:not(.plain)),
|
||||
:global(a.btn) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -70,19 +87,29 @@
|
||||
fill: $text-color;
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
transition: background-color 100ms ease, opacity 100ms ease;
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
opacity 100ms ease;
|
||||
|
||||
& :global(svg) {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&:not(:disabled) {
|
||||
&:hover,
|
||||
&:focus-visible,
|
||||
&.active {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,7 +143,9 @@
|
||||
border: 1px solid white;
|
||||
border-radius: 6px;
|
||||
z-index: 99999;
|
||||
transition: top 100ms ease, left 100ms ease;
|
||||
transition:
|
||||
top 100ms ease,
|
||||
left 100ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -132,7 +161,7 @@
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
div {
|
||||
.notif {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
@@ -145,6 +174,10 @@
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -157,8 +190,8 @@
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: #f3555a;
|
||||
border: 1px solid #f3555a;
|
||||
background-color: $error;
|
||||
border: 1px solid $error;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
@@ -167,8 +200,8 @@
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: #28a745;
|
||||
border: 1px solid #28a745;
|
||||
background-color: $success;
|
||||
border: 1px solid $success;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
@@ -181,6 +214,12 @@
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
padding-left: 9px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -193,4 +232,20 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.small-scrollbar {
|
||||
&::-webkit-scrollbar {
|
||||
width: 4.5px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(155, 155, 155, 0.5);
|
||||
border-radius: 20px;
|
||||
border: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,18 +4,32 @@
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { isTouch } from "@/lib/util/helpers";
|
||||
import { activeFilter, clearAllStores, watchedList } from "@/store";
|
||||
import { isTouch, parseTokenPayload, userHasPermission } from "@/lib/util/helpers";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import {
|
||||
activeFilters,
|
||||
activeSort,
|
||||
clearAllStores,
|
||||
searchQuery,
|
||||
userInfo,
|
||||
userSettings,
|
||||
watchedList
|
||||
} from "@/store";
|
||||
import { type Filters, UserPermission } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const username = localStorage.getItem("username");
|
||||
|
||||
let navEl: HTMLElement;
|
||||
let searchTimeout: number;
|
||||
let subMenuShown = false;
|
||||
let filterMenuShown = false;
|
||||
let sortMenuShown = false;
|
||||
|
||||
$: filter = $activeFilter;
|
||||
$: sort = $activeSort;
|
||||
$: filter = $activeFilters;
|
||||
$: settings = $userSettings;
|
||||
$: user = $userInfo;
|
||||
|
||||
function handleProfileClick() {
|
||||
if (!localStorage.getItem("token")) {
|
||||
@@ -23,14 +37,10 @@
|
||||
} else {
|
||||
subMenuShown = !subMenuShown;
|
||||
filterMenuShown = false;
|
||||
sortMenuShown = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFilterClick() {
|
||||
filterMenuShown = !filterMenuShown;
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
function handleSearch(ev: KeyboardEvent) {
|
||||
if (
|
||||
ev.key === "Tab" ||
|
||||
@@ -49,8 +59,14 @@
|
||||
const query = target?.value.trim();
|
||||
if (!query) return;
|
||||
if (query) {
|
||||
// Enable autofocus before running `goto` because on chromium
|
||||
// the .focus() call won't work, even after a timeout.
|
||||
// Using autofocus seems to work. Disables after goto runs.
|
||||
// https://github.com/sbondCo/Watcharr/issues/169
|
||||
target.autofocus = true;
|
||||
goto(`/search/${query}`).then(() => {
|
||||
target?.focus();
|
||||
target.autofocus = false;
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -69,19 +85,59 @@
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
async function getWatchedList() {
|
||||
function serverSettings() {
|
||||
goto("/server");
|
||||
subMenuShown = false;
|
||||
}
|
||||
|
||||
function shareWatchedList() {
|
||||
const nid = notify({ type: "loading", text: "Getting link" });
|
||||
const ud = parseTokenPayload();
|
||||
console.log(ud);
|
||||
if (ud?.userId && ud?.username) {
|
||||
const shareLink = `${window.location.origin}/lists/${ud.userId}/${ud.username}`;
|
||||
navigator.clipboard
|
||||
.writeText(shareLink)
|
||||
.then(() => {
|
||||
notify({ id: nid, type: "success", text: "Copied share link" });
|
||||
})
|
||||
.catch((r) => {
|
||||
console.error("Failed to copy list share link", r);
|
||||
notify({
|
||||
id: nid,
|
||||
type: "error",
|
||||
text: `Failed to copy share link:<br/><a href="${shareLink}" target="_blank">${shareLink}</a>`,
|
||||
time: 20000
|
||||
});
|
||||
});
|
||||
} else {
|
||||
notify({ id: nid, type: "error", text: "Failed to get link" });
|
||||
}
|
||||
}
|
||||
|
||||
async function getInitialData() {
|
||||
if (localStorage.getItem("token")) {
|
||||
const w = await axios.get("/watched");
|
||||
const [w, u, s] = await Promise.all([
|
||||
axios.get("/watched"),
|
||||
axios.get("/user"),
|
||||
axios.get("/user/settings")
|
||||
]);
|
||||
if (w?.data?.length > 0) {
|
||||
watchedList.update((wl) => (wl = w.data));
|
||||
}
|
||||
if (u?.data) {
|
||||
userInfo.update((ui) => (ui = u.data));
|
||||
}
|
||||
if (s?.data) {
|
||||
userSettings.update((us) => (us = s.data));
|
||||
}
|
||||
} else {
|
||||
goto("/login?again=1");
|
||||
}
|
||||
}
|
||||
|
||||
function filterClicked(type: string, modeType: string = "UPDOWN") {
|
||||
const af = get(activeFilter);
|
||||
function sortClicked(type: string, modeType: string = "UPDOWN") {
|
||||
const af = get(activeSort);
|
||||
let mode: string;
|
||||
if (modeType === "UPDOWN") {
|
||||
mode = "UP";
|
||||
@@ -103,46 +159,163 @@
|
||||
console.error("filterClicked() ran without a valid modeType:", modeType);
|
||||
return;
|
||||
}
|
||||
activeFilter.update((af) => (af = [type, mode]));
|
||||
activeSort.update((af) => (af = [type, mode]));
|
||||
}
|
||||
|
||||
function filterClicked(type: keyof Filters, f: string) {
|
||||
const af = get(activeFilters);
|
||||
if (af[type]?.includes(f)) {
|
||||
af[type] = af[type]?.filter((a) => a !== f);
|
||||
} else {
|
||||
af[type]?.push(f);
|
||||
}
|
||||
activeFilters.update((a) => (a = af));
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (navEl) {
|
||||
let scroll = window.scrollY;
|
||||
window.document.addEventListener("scroll", (ev: Event) => {
|
||||
if (scroll > window.scrollY) {
|
||||
navEl?.classList.remove("scrolled-down");
|
||||
} else {
|
||||
navEl?.classList.add("scrolled-down");
|
||||
subMenuShown = false;
|
||||
filterMenuShown = false;
|
||||
sortMenuShown = false;
|
||||
}
|
||||
scroll = window.scrollY;
|
||||
});
|
||||
} else {
|
||||
console.error("navEl doesn't exist, failed to initialize up/down listener");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav>
|
||||
<nav bind:this={navEl}>
|
||||
<a href="/">
|
||||
<span class="large">Watcharr</span>
|
||||
<span class="small">W</span>
|
||||
</a>
|
||||
<input type="text" placeholder="Search" on:keydown={handleSearch} />
|
||||
<input type="text" placeholder="Search" bind:value={$searchQuery} on:keydown={handleSearch} />
|
||||
<div class="btns">
|
||||
<!-- Only show on watched list -->
|
||||
{#if $page.url?.pathname === "/"}
|
||||
<button class="plain filter" on:click={handleFilterClick}>
|
||||
<button
|
||||
class="plain other filter"
|
||||
on:click={() => {
|
||||
filterMenuShown = !filterMenuShown;
|
||||
sortMenuShown = false;
|
||||
subMenuShown = false;
|
||||
}}
|
||||
>
|
||||
<Icon i="filter" />
|
||||
</button>
|
||||
{#if filterMenuShown}
|
||||
<div class="filter-menu">
|
||||
<button
|
||||
class="plain other sort"
|
||||
on:click={() => {
|
||||
sortMenuShown = !sortMenuShown;
|
||||
filterMenuShown = false;
|
||||
subMenuShown = false;
|
||||
}}
|
||||
>
|
||||
<Icon i="sort" />
|
||||
</button>
|
||||
{#if sortMenuShown}
|
||||
<div class="menu sort-menu">
|
||||
<button
|
||||
class={`plain ${filter[0] == "DATEADDED" ? filter[1].toLowerCase() : ""}`}
|
||||
on:click={() => filterClicked("DATEADDED")}
|
||||
class={`plain ${sort[0] == "DATEADDED" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("DATEADDED")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter[0] == "ALPHA" ? filter[1].toLowerCase() : ""}`}
|
||||
on:click={() => filterClicked("ALPHA")}
|
||||
class={`plain ${sort[0] == "LASTCHANGED" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("LASTCHANGED")}
|
||||
>
|
||||
Last Changed
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("RATING")}
|
||||
>
|
||||
Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "ALPHA" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("ALPHA")}
|
||||
>
|
||||
Alphabetical
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if filterMenuShown}
|
||||
<div class="menu filter-menu">
|
||||
<h4 class="norm sm-caps">type</h4>
|
||||
<div class="type-filter">
|
||||
<button
|
||||
class={`${filter.type.includes("tv") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "tv")}
|
||||
>
|
||||
SHOW
|
||||
</button>
|
||||
<button
|
||||
class={`${filter.type.includes("movie") ? "active" : ""}`}
|
||||
on:click={() => filterClicked("type", "movie")}
|
||||
>
|
||||
MOVIE
|
||||
</button>
|
||||
</div>
|
||||
<h4 class="norm sm-caps">status</h4>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("planned") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "planned")}
|
||||
>
|
||||
planned
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("watching") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "watching")}
|
||||
>
|
||||
watching
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("finished") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "finished")}
|
||||
>
|
||||
finished
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("hold") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "hold")}
|
||||
>
|
||||
held
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${filter.status.includes("dropped") ? "on" : ""}`}
|
||||
on:click={() => filterClicked("status", "dropped")}
|
||||
>
|
||||
dropped
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
<button class="plain other discover" on:click={() => goto("/discover")}>
|
||||
<Icon i="compass" />
|
||||
</button>
|
||||
<button class="plain face" on:click={handleProfileClick}>:)</button>
|
||||
{#if subMenuShown}
|
||||
<div class="face-menu">
|
||||
{#if username}
|
||||
<h5 title={username}>Hi {username}!</h5>
|
||||
<div class="menu face-menu">
|
||||
{#if user?.username}
|
||||
<h5 title={user.username}>Hi {user.username}!</h5>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => profile()}>Profile</button>
|
||||
{#if !settings?.private}
|
||||
<button class="plain" on:click={() => shareWatchedList()}>Share List</button>
|
||||
{/if}
|
||||
{#if user && userHasPermission(user.permissions, UserPermission.PERM_ADMIN)}
|
||||
<button class="plain" on:click={() => serverSettings()}>Settings</button>
|
||||
{/if}
|
||||
<button class="plain" on:click={() => logout()}>Logout</button>
|
||||
<!-- svelte-ignore missing-declaration -->
|
||||
<span>v{__WATCHARR_VERSION__}</span>
|
||||
@@ -151,12 +324,12 @@
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{#await getWatchedList()}
|
||||
{#await getInitialData()}
|
||||
<Spinner />
|
||||
{:then}
|
||||
<slot />
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load watched list!" error={err} />
|
||||
<PageError pretty="Failed to retrieve user data!" error={err} />
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
@@ -164,15 +337,32 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 10px 20px 28px 20px;
|
||||
position: relative;
|
||||
margin-bottom: 20px;
|
||||
padding: 10px 20px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
gap: 20px;
|
||||
z-index: 999999999;
|
||||
backdrop-filter: blur(2.5px) saturate(120%);
|
||||
background-color: $nav-color;
|
||||
transition: top 200ms ease-in-out;
|
||||
|
||||
&:global(.scrolled-down) {
|
||||
top: -71px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
"Shrikhand",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 35px;
|
||||
transition: -webkit-text-stroke 150ms ease, color 150ms ease, font-weight 150ms ease;
|
||||
transition:
|
||||
-webkit-text-stroke 150ms ease,
|
||||
color 150ms ease,
|
||||
font-weight 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
@@ -206,7 +396,9 @@
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
box-shadow: 4px 4px 0px 0px $text-color;
|
||||
transition: width 150ms ease, box-shadow 150ms ease;
|
||||
transition:
|
||||
width 150ms ease,
|
||||
box-shadow 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
@@ -217,68 +409,82 @@
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 30px;
|
||||
/* gap: 20px; */
|
||||
|
||||
button.filter {
|
||||
button.other {
|
||||
padding-top: 2px;
|
||||
width: 28px;
|
||||
transition: fill 150ms ease, stroke 150ms ease, stroke-width 150ms ease;
|
||||
transition:
|
||||
fill 150ms ease,
|
||||
stroke 150ms ease,
|
||||
stroke-width 150ms ease;
|
||||
fill: $text-color;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
stroke: $bg-color;
|
||||
stroke-width: 10px;
|
||||
:global(path) {
|
||||
fill: none;
|
||||
stroke: $text-color;
|
||||
stroke-width: 30px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.filter {
|
||||
margin-right: 15px;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
:global(path) {
|
||||
stroke-width: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button.sort {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
button.discover {
|
||||
margin-right: 17px;
|
||||
transition:
|
||||
fill 150ms ease,
|
||||
stroke 150ms ease,
|
||||
stroke-width 150ms ease,
|
||||
transform 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: rotate(60deg);
|
||||
}
|
||||
}
|
||||
|
||||
button.face {
|
||||
font-family: "Shrikhand", system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-family:
|
||||
"Shrikhand",
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 25px;
|
||||
transform: rotate(90deg);
|
||||
cursor: pointer;
|
||||
transition: -webkit-text-stroke 150ms ease, color 150ms ease;
|
||||
margin-left: 3px;
|
||||
transition:
|
||||
-webkit-text-stroke 150ms ease,
|
||||
color 150ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
color: white;
|
||||
-webkit-text-stroke: 1.5px black;
|
||||
color: $bg-color;
|
||||
-webkit-text-stroke: 1.5px $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
div.filter-menu {
|
||||
width: 180px;
|
||||
|
||||
& > button {
|
||||
position: relative;
|
||||
|
||||
&.down::before {
|
||||
content: "\2193";
|
||||
}
|
||||
|
||||
&.up::before {
|
||||
content: "\2191";
|
||||
}
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
div.menu {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
right: 3px;
|
||||
top: 55px;
|
||||
width: 125px;
|
||||
padding: 10px;
|
||||
@@ -302,9 +508,10 @@
|
||||
cursor: pointer;
|
||||
transition: background-color 200ms ease;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
color: white;
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,6 +522,91 @@
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
div.sort-menu {
|
||||
width: 180px;
|
||||
|
||||
& > button {
|
||||
position: relative;
|
||||
|
||||
&.down::before {
|
||||
content: "\2193";
|
||||
}
|
||||
|
||||
&.up::before {
|
||||
content: "\2191";
|
||||
}
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div.filter-menu {
|
||||
width: 180px;
|
||||
right: 35px;
|
||||
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
& > button {
|
||||
text-transform: capitalize;
|
||||
position: relative;
|
||||
|
||||
&.on::before {
|
||||
content: "\2713";
|
||||
}
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 12px;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.type-filter {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
padding: 8px 0;
|
||||
width: 100%;
|
||||
|
||||
&:first-of-type {
|
||||
border-right: 0;
|
||||
border-radius: 5px 0 0 5px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-radius: 0 5px 5px 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 580px) {
|
||||
|
||||
@@ -1,45 +1,10 @@
|
||||
<script lang="ts">
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
import { removeWatched, updateWatched } from "@/lib/util/api";
|
||||
import { activeFilter, watchedList } from "@/store";
|
||||
|
||||
$: filter = $activeFilter;
|
||||
$: watched = $watchedList.sort((a, b) => {
|
||||
if (filter[0] === "DATEADDED" && filter[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (filter[0] === "ALPHA") {
|
||||
if (filter[1] === "UP") return a.content.title.localeCompare(b.content.title);
|
||||
else if (filter[1] === "DOWN") return b.content.title.localeCompare(a.content.title);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
});
|
||||
import WatchedList from "@/lib/WatchedList.svelte";
|
||||
import { watchedList } from "@/store";
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Watched List</title>
|
||||
</svelte:head>
|
||||
|
||||
<PosterList>
|
||||
{#if watched?.length > 0}
|
||||
{#each watched as w (w.id)}
|
||||
<Poster
|
||||
media={{
|
||||
id: w.content.tmdbId,
|
||||
poster_path: w.content.poster_path,
|
||||
title: w.content.title,
|
||||
overview: w.content.overview,
|
||||
media_type: w.content.type
|
||||
}}
|
||||
rating={w.rating}
|
||||
status={w.status}
|
||||
onStatusChanged={(t) => updateWatched(w.content.tmdbId, w.content.type, t)}
|
||||
onRatingChanged={(r) => updateWatched(w.content.tmdbId, w.content.type, undefined, r)}
|
||||
onDeleteClicked={() => removeWatched(w.id)}
|
||||
/>
|
||||
{/each}
|
||||
{:else}
|
||||
You don't have any watched content yet!
|
||||
{/if}
|
||||
</PosterList>
|
||||
<WatchedList list={$watchedList} />
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
<script lang="ts">
|
||||
import { watchedList } from "@/store";
|
||||
import PageError from "@/lib/PageError.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import axios from "axios";
|
||||
import type {
|
||||
TMDBDiscoverMovies,
|
||||
TMDBDiscoverShows,
|
||||
TMDBTrendingAll,
|
||||
TMDBUpcomingMovies,
|
||||
TMDBUpcomingShows
|
||||
} from "@/types";
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import { getWatchedDependedProps } from "@/lib/util/helpers";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
|
||||
$: wList = $watchedList;
|
||||
|
||||
async function allTrending() {
|
||||
return (await axios.get(`/content/trending`)).data as TMDBTrendingAll;
|
||||
}
|
||||
|
||||
async function trendingMovies() {
|
||||
return (await axios.get(`/content/discover/movies`)).data as TMDBDiscoverMovies;
|
||||
}
|
||||
|
||||
async function trendingShows() {
|
||||
return (await axios.get(`/content/discover/tv`)).data as TMDBDiscoverShows;
|
||||
}
|
||||
|
||||
async function upcomingMovies() {
|
||||
return (await axios.get(`/content/upcoming/movies`)).data as TMDBUpcomingMovies;
|
||||
}
|
||||
|
||||
async function upcomingShows() {
|
||||
return (await axios.get(`/content/upcoming/tv`)).data as TMDBUpcomingShows;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Discover Content</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="page">
|
||||
<h1>Discover</h1>
|
||||
|
||||
<h2 class="norm">Trending Today</h2>
|
||||
{#await allTrending()}
|
||||
<Spinner />
|
||||
{:then trending}
|
||||
<PosterList type="vertical">
|
||||
{#each trending.results as trend}
|
||||
<!-- Possible a person gets returned, but i don't think anyone cares for them -->
|
||||
{#if trend.media_type === "movie" || trend.media_type === "tv"}
|
||||
<Poster
|
||||
media={{ ...trend, media_type: trend.media_type }}
|
||||
{...getWatchedDependedProps(trend.id, trend.media_type, wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load currently trending!" error={err} />
|
||||
{/await}
|
||||
|
||||
<h2 class="norm">Trending Movies</h2>
|
||||
{#await trendingMovies()}
|
||||
<Spinner />
|
||||
{:then movies}
|
||||
<PosterList type="vertical">
|
||||
{#each movies.results as movie}
|
||||
<Poster
|
||||
media={{ ...movie, media_type: "movie" }}
|
||||
{...getWatchedDependedProps(movie.id, "movie", wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load discovered movies!" error={err} />
|
||||
{/await}
|
||||
|
||||
<h2 class="norm">Trending Shows</h2>
|
||||
{#await trendingShows()}
|
||||
<Spinner />
|
||||
{:then shows}
|
||||
<PosterList type="vertical">
|
||||
{#each shows.results as tv}
|
||||
<Poster
|
||||
media={{ ...tv, media_type: "tv" }}
|
||||
{...getWatchedDependedProps(tv.id, "tv", wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load discovered shows!" error={err} />
|
||||
{/await}
|
||||
|
||||
<h2 class="norm">Upcoming Movies</h2>
|
||||
{#await upcomingMovies()}
|
||||
<Spinner />
|
||||
{:then shows}
|
||||
<PosterList type="vertical">
|
||||
{#each shows.results as tv}
|
||||
<Poster
|
||||
media={{ ...tv, media_type: "movie" }}
|
||||
{...getWatchedDependedProps(tv.id, "movie", wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load upcoming movies!" error={err} />
|
||||
{/await}
|
||||
|
||||
<h2 class="norm">Upcoming Shows</h2>
|
||||
{#await upcomingShows()}
|
||||
<Spinner />
|
||||
{:then shows}
|
||||
<PosterList type="vertical">
|
||||
{#each shows.results as tv}
|
||||
<Poster
|
||||
media={{ ...tv, media_type: "tv" }}
|
||||
{...getWatchedDependedProps(tv.id, "tv", wList)}
|
||||
small={true}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
{:catch err}
|
||||
<PageError pretty="Failed to load upcoming shows!" error={err} />
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.page {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 20px 50px;
|
||||
max-width: 1200px;
|
||||
|
||||
h1 {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,221 @@
|
||||
<!--
|
||||
/import is for getting the user to select
|
||||
the file they want to import and reading
|
||||
it. The data is set in a store for
|
||||
/import/process to process.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { importedList } from "@/store";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
let isLoading = false;
|
||||
|
||||
function processFiles(files?: FileList | null) {
|
||||
try {
|
||||
console.log("processFiles", files);
|
||||
if (!files) {
|
||||
console.error("processFiles", "No files to process!");
|
||||
return;
|
||||
}
|
||||
isLoading = true;
|
||||
if (files.length > 1) {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Only one file at a time is supported. Continuing with the first.",
|
||||
time: 6000
|
||||
});
|
||||
}
|
||||
// Currently only support for importing one file at a time
|
||||
const file = files[0];
|
||||
if (file.type !== "text/plain") {
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Currently only text files are supported"
|
||||
});
|
||||
isLoading = false;
|
||||
isDragOver = false;
|
||||
return;
|
||||
}
|
||||
const r = new FileReader();
|
||||
r.addEventListener(
|
||||
"load",
|
||||
() => {
|
||||
if (r.result) {
|
||||
importedList.set({
|
||||
file,
|
||||
data: r.result.toString()
|
||||
});
|
||||
goto("/import/process");
|
||||
}
|
||||
},
|
||||
false
|
||||
);
|
||||
r.readAsText(file);
|
||||
} catch (err) {
|
||||
isLoading = false;
|
||||
notify({ type: "error", text: "Failed to read file!" });
|
||||
console.error("import: Failed to read file!", err);
|
||||
}
|
||||
}
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!localStorage.getItem("token")) {
|
||||
goto("/login");
|
||||
}
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", (ev) => {
|
||||
processFiles(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<span class="header">
|
||||
<h2>Import Your Watchlist</h2>
|
||||
<h5 class="norm">beta</h5>
|
||||
</span>
|
||||
<div class="big-btns">
|
||||
{#if isLoading}
|
||||
<Spinner />
|
||||
{:else}
|
||||
<button
|
||||
on:click={importFile}
|
||||
on:dragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
on:dragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
on:dragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
on:drop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
processFiles(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : "document"} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import
|
||||
{:else}
|
||||
Browse
|
||||
{/if}
|
||||
</h4>
|
||||
<!-- <h5 class="norm">Or Drag And Drop</h5> -->
|
||||
</div>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" bind:this={fileInput} />
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 0 30px;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 400px;
|
||||
max-width: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
|
||||
h5 {
|
||||
margin-top: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.big-btns {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,489 @@
|
||||
<!--
|
||||
/import/process is for processing the
|
||||
selected files data. Here it will be
|
||||
displayed and imported.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import DropDown from "@/lib/DropDown.svelte";
|
||||
import Error from "@/lib/Error.svelte";
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Poster from "@/lib/Poster.svelte";
|
||||
import PosterList from "@/lib/PosterList.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import SpinnerTiny from "@/lib/SpinnerTiny.svelte";
|
||||
import { sleep } from "@/lib/util/helpers";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { importedList, parsedImportedList, watchedList } from "@/store";
|
||||
import {
|
||||
ImportResponseType,
|
||||
type ImportResponse,
|
||||
type ContentSearchTv,
|
||||
type ContentSearchMovie,
|
||||
type ImportedList
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { onDestroy } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
const wList = get(watchedList);
|
||||
|
||||
interface ImportedListItemMultiProblem {
|
||||
original: ImportedList;
|
||||
results: (ContentSearchMovie | ContentSearchTv)[];
|
||||
callback: (err: Error | string | undefined) => void;
|
||||
}
|
||||
|
||||
let rList: ImportedList[] = [];
|
||||
let isImporting = false;
|
||||
let cancelled = false;
|
||||
|
||||
onDestroy(() => {
|
||||
cancelled = true;
|
||||
});
|
||||
|
||||
// Set when current item being imported gets an IMPORT_MULTI
|
||||
// response, which then shows the modal for user to pick correct item.
|
||||
let importMultiItem: ImportedListItemMultiProblem | undefined;
|
||||
|
||||
async function getList() {
|
||||
const list = get(importedList);
|
||||
if (!list) {
|
||||
console.log("import/process, no list, returning to /import");
|
||||
goto("/import");
|
||||
return;
|
||||
}
|
||||
console.log("getList", list);
|
||||
if (list?.file.type === "text/plain") {
|
||||
// Regex to match a year in between brackets,
|
||||
// which we assume is the release year of content.
|
||||
const yearRegex = new RegExp(/\([0-9]{4}\)/);
|
||||
const s = list.data.split("\n");
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const el = s[i]?.trim();
|
||||
if (el) {
|
||||
const l: ImportedList = { name: el };
|
||||
const year = el.match(yearRegex);
|
||||
if (year && year.length > 0) {
|
||||
l.year = year[0].replaceAll(/\(|\)/g, "");
|
||||
l.name = l.name.replace(yearRegex, "").trim();
|
||||
}
|
||||
rList.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: remove duplicate names in list
|
||||
return list;
|
||||
}
|
||||
|
||||
function addRow(ev: FocusEvent & { currentTarget: EventTarget & HTMLInputElement }) {
|
||||
if (!ev.currentTarget.value) {
|
||||
return;
|
||||
}
|
||||
const lo = { name: ev.currentTarget.value } as ImportedList;
|
||||
const yearEl = document.getElementById("addYear") as HTMLInputElement;
|
||||
if (yearEl?.value) {
|
||||
lo.year = yearEl.value;
|
||||
}
|
||||
rList.push(lo);
|
||||
rList = rList;
|
||||
ev.currentTarget.value = "";
|
||||
yearEl.value = "";
|
||||
}
|
||||
|
||||
function removeRow(l: ImportedList) {
|
||||
rList = rList.filter((r) => r.name !== l.name);
|
||||
rList = rList;
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
console.log(rList);
|
||||
isImporting = true;
|
||||
window.scrollTo(0, 0);
|
||||
for (let i = 0; i < rList.length; i++) {
|
||||
if (cancelled) {
|
||||
notify({ type: "error", text: "Importing Cancelled" });
|
||||
return;
|
||||
}
|
||||
const li = rList[i];
|
||||
try {
|
||||
console.log("Importing", li);
|
||||
await doImport(li);
|
||||
await sleep(2000);
|
||||
} catch (err) {
|
||||
console.error("Failed to import item:", li, "reason:", err);
|
||||
}
|
||||
}
|
||||
importedList.set(undefined);
|
||||
if (
|
||||
rList.some(
|
||||
(i) =>
|
||||
i.state == ImportResponseType.IMPORT_FAILED ||
|
||||
i.state == ImportResponseType.IMPORT_NOTFOUND
|
||||
)
|
||||
) {
|
||||
// Some items failed.. go to some-failed
|
||||
parsedImportedList.set(rList);
|
||||
goto("/import/some-failed");
|
||||
} else {
|
||||
notify({ type: "success", text: "All content successfully imported!" });
|
||||
goto("/");
|
||||
}
|
||||
}
|
||||
|
||||
async function doImport(item: ImportedList) {
|
||||
if (!item.name?.trim()) {
|
||||
item.state = ImportResponseType.IMPORT_NOTFOUND;
|
||||
rList = rList;
|
||||
return;
|
||||
}
|
||||
const resp = await axios.post<ImportResponse>("/import", item);
|
||||
return new Promise((res, rej) => {
|
||||
if (resp.data.type === ImportResponseType.IMPORT_MULTI) {
|
||||
console.log("Import found multiple responses for content", resp.data);
|
||||
let results = resp.data.results;
|
||||
if (item.year) {
|
||||
results = results.sort((a, b) => {
|
||||
try {
|
||||
const ar = a.media_type === "movie" ? a.release_date : a.first_air_date;
|
||||
const ay = ar ? new Date(Date.parse(ar)).getFullYear() : undefined;
|
||||
const br = b.media_type === "movie" ? b.release_date : b.first_air_date;
|
||||
const by = br ? new Date(Date.parse(br)).getFullYear() : undefined;
|
||||
if (ay == item.year) return -1;
|
||||
else if (by == item.year) return 1;
|
||||
} catch (err) {
|
||||
console.error("doImport: results sort failed", err);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
importMultiItem = {
|
||||
original: item,
|
||||
results: resp.data.results,
|
||||
callback: (err) => {
|
||||
if (err) {
|
||||
item.state = ImportResponseType.IMPORT_NOTFOUND;
|
||||
rList = rList;
|
||||
rej(err);
|
||||
} else {
|
||||
res(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
} else if (resp.data.type === ImportResponseType.IMPORT_SUCCESS) {
|
||||
item.state = ImportResponseType.IMPORT_SUCCESS;
|
||||
const w = resp.data.watchedEntry;
|
||||
if (w) {
|
||||
const release =
|
||||
w.content.type === "movie" ? w.content.release_date : w.content.first_air_date;
|
||||
if (release) item.year = String(new Date(Date.parse(release)).getFullYear());
|
||||
item.type = w.content.type;
|
||||
wList.push(w);
|
||||
watchedList.update(() => wList);
|
||||
}
|
||||
rList = rList;
|
||||
res(0);
|
||||
} else {
|
||||
item.state = resp.data.type;
|
||||
rList = rList;
|
||||
res(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getList()}
|
||||
<Spinner />
|
||||
{:then list}
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
{#if rList}
|
||||
<h2>Importing {list?.file.name ? list.file.name : ""}</h2>
|
||||
<h5 class="norm">
|
||||
{#if !isImporting}
|
||||
Review your imported list and fix any problems.
|
||||
{:else}
|
||||
You can fix any failed imports when the process completes.
|
||||
{/if}
|
||||
</h5>
|
||||
<table class={isImporting ? "is-importing" : ""}>
|
||||
<tr>
|
||||
{#if isImporting}
|
||||
<th class="loading-col"></th>
|
||||
{/if}
|
||||
<th>Name</th>
|
||||
<th>Year</th>
|
||||
<th>Type</th>
|
||||
{#if !isImporting}
|
||||
<th></th>
|
||||
{/if}
|
||||
</tr>
|
||||
{#each rList as l}
|
||||
<tr>
|
||||
{#if isImporting}
|
||||
<td class="icon-cell">
|
||||
<div>
|
||||
{#if !l.state}
|
||||
<SpinnerTiny style="width: 13px;" />
|
||||
{:else if l.state === ImportResponseType.IMPORT_SUCCESS}
|
||||
<Icon i="check" wh={22} />
|
||||
{:else if l.state === ImportResponseType.IMPORT_NOTFOUND}
|
||||
<Icon i="close" wh={22} />
|
||||
{:else if l.state === ImportResponseType.IMPORT_FAILED}
|
||||
<Icon i="close" wh={22} />
|
||||
{:else if l.state === ImportResponseType.IMPORT_EXISTS}
|
||||
<Icon i="check" wh={22} />
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
{/if}
|
||||
<td><input class="plain" bind:value={l.name} disabled={isImporting} /></td>
|
||||
<td class="year">
|
||||
<input
|
||||
class="plain"
|
||||
bind:value={l.year}
|
||||
placeholder="YYYY"
|
||||
type="number"
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</td>
|
||||
<td class="type">
|
||||
<DropDown
|
||||
options={["movie", "tv"]}
|
||||
bind:active={l.type}
|
||||
placeholder="Type"
|
||||
blendIn={true}
|
||||
disabled={isImporting}
|
||||
/>
|
||||
</td>
|
||||
{#if !isImporting}
|
||||
<td>
|
||||
<button
|
||||
class="plain delete"
|
||||
on:click={() => {
|
||||
removeRow(l);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" wh="25" />
|
||||
</button>
|
||||
</td>
|
||||
{/if}
|
||||
</tr>
|
||||
{/each}
|
||||
{#if !isImporting}
|
||||
<tr>
|
||||
<td><input class="plain" placeholder="Name" on:blur={addRow} /></td>
|
||||
<td class="year">
|
||||
<input class="plain" id="addYear" placeholder="YYYY" type="number" />
|
||||
</td>
|
||||
<td class="type"></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
{/if}
|
||||
</table>
|
||||
<div class="btns">
|
||||
<button on:click={() => goto("/import")}><Icon i="arrow" />Back</button>
|
||||
<button on:click={startImport} disabled={isImporting}>Start Importing</button>
|
||||
</div>
|
||||
{:else}
|
||||
<h2>No list</h2>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Multiple results found modal -->
|
||||
{#if importMultiItem}
|
||||
<Modal
|
||||
title="Multiple Results Found"
|
||||
desc="Select the correct item for {importMultiItem.original.name}"
|
||||
onClose={() => {
|
||||
importMultiItem?.callback("closed results modal");
|
||||
importMultiItem = undefined;
|
||||
}}
|
||||
>
|
||||
<PosterList type="vertical">
|
||||
{#each importMultiItem.results as r}
|
||||
<Poster
|
||||
media={r}
|
||||
small={true}
|
||||
disableInteraction={true}
|
||||
hideButtons={true}
|
||||
onClick={async () => {
|
||||
const item = rList.find((i) => i.name === importMultiItem?.original.name);
|
||||
if (item) {
|
||||
item.tmdbId = r.id;
|
||||
item.type = r.media_type;
|
||||
try {
|
||||
await doImport(item);
|
||||
importMultiItem?.callback(undefined);
|
||||
} catch (err) {
|
||||
importMultiItem?.callback(String(err));
|
||||
}
|
||||
importMultiItem = undefined;
|
||||
} else {
|
||||
// TODO: show error notif and update state with error icon
|
||||
}
|
||||
console.log("multi: Poster clicked", r);
|
||||
}}
|
||||
/>
|
||||
{/each}
|
||||
</PosterList>
|
||||
</Modal>
|
||||
{/if}
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to process list!" />
|
||||
{/await}
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
margin-top: 20px;
|
||||
width: 100%;
|
||||
border-spacing: 0px;
|
||||
border: 1px solid $accent-color;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
|
||||
th {
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
transition: padding 100ms ease;
|
||||
|
||||
&:first-of-type {
|
||||
border-top-left-radius: 10px;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
border-top-right-radius: 10px;
|
||||
}
|
||||
|
||||
&.loading-col {
|
||||
width: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
tr {
|
||||
th {
|
||||
background-color: $accent-color;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
td:first-of-type {
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
td:last-of-type {
|
||||
border-bottom-right-radius: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
&:nth-child(odd) td {
|
||||
background-color: $accent-color;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-importing {
|
||||
th {
|
||||
padding-left: 3px;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-left: 3px;
|
||||
|
||||
input {
|
||||
padding: 7px 0;
|
||||
|
||||
&:focus {
|
||||
padding: 7px 5px;
|
||||
padding-left: 3px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 5px;
|
||||
|
||||
&.icon-cell {
|
||||
padding-right: 3px;
|
||||
|
||||
& > div {
|
||||
display: flex;
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
&.year {
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
&.type {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
input {
|
||||
background: transparent;
|
||||
color: $text-color;
|
||||
border: 0;
|
||||
font-size: 16px;
|
||||
padding: 0;
|
||||
padding: 7px 10px;
|
||||
transition: padding 100ms ease;
|
||||
|
||||
&[type="number"] {
|
||||
appearance: textfield;
|
||||
|
||||
&::-webkit-outer-spin-button,
|
||||
&::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
&.delete {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: $placeholder-color;
|
||||
|
||||
&:hover {
|
||||
color: $error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.btns {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
margin-top: 20px;
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
gap: 3px;
|
||||
|
||||
&:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
/import/some-failed shows the user the
|
||||
failed imports so they can manually import
|
||||
them instead.
|
||||
|
||||
TODO: It would be better if this page didn't exist
|
||||
and we instead just let users `re-try` on import/process
|
||||
table. If any failed, we can hide the success ones (and
|
||||
add a toggle to show successful ones), user can edit any
|
||||
failed rows and try import each again.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { parsedImportedList } from "@/store";
|
||||
import { ImportResponseType, type ImportedList } from "@/types";
|
||||
import { onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
let failed: ImportedList[] = [];
|
||||
let successCount = 0;
|
||||
|
||||
onMount(() => {
|
||||
let list = get(parsedImportedList);
|
||||
if (list) {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const item = list[i];
|
||||
console.log(item);
|
||||
if (
|
||||
item.state === ImportResponseType.IMPORT_FAILED ||
|
||||
item.state === ImportResponseType.IMPORT_NOTFOUND
|
||||
) {
|
||||
failed.push(item);
|
||||
failed = failed;
|
||||
} else {
|
||||
successCount++;
|
||||
}
|
||||
}
|
||||
console.log("failedlen", failed.length);
|
||||
} else {
|
||||
goto("/import");
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
<div class="inner">
|
||||
<h2>Some Content Failed To Import</h2>
|
||||
<h5 class="norm">You can search for the failed imports and manually add them.</h5>
|
||||
<h4 class="norm">{successCount} succeeded and {failed.length} failed.</h4>
|
||||
|
||||
{#if failed}
|
||||
<ul>
|
||||
{#each failed as l}
|
||||
<li>
|
||||
<span>{l.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
No List
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.content {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 0 30px 30px 30px;
|
||||
|
||||
.inner {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
min-width: 400px;
|
||||
max-width: 600px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 5px;
|
||||
margin: 10px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
background-color: $accent-color;
|
||||
border-radius: 5px;
|
||||
|
||||
a {
|
||||
margin-left: auto;
|
||||
|
||||
button {
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||