Compare commits
393 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e7702b16b | |||
| fa2a4a40d0 | |||
| 203e4a31ef | |||
| 5f2c1977ee | |||
| 5860fd2783 | |||
| 3c2cbf55c6 | |||
| 565d90dbf0 | |||
| b6d6e66f04 | |||
| 0b4f3c8604 | |||
| 0a5a4e39f5 | |||
| 2d8def18ed | |||
| d53f62e96c | |||
| 4dd0b419e0 | |||
| 41948f15ba | |||
| 49f0739503 | |||
| 1c763e9761 | |||
| 18a30639b9 | |||
| a3847ccb3c | |||
| 5f5eb3e9f2 | |||
| 3bf1f64f25 | |||
| 82b5b30e1c | |||
| 303761dc4c | |||
| 16012da267 | |||
| 74bceee57b | |||
| bc0aa56ced | |||
| 84753ce829 | |||
| 780d7c55b1 | |||
| 9318df3e14 | |||
| d0c0ec1f67 | |||
| 32b3b792e5 | |||
| 5018a229c0 | |||
| 6e20ab85c3 | |||
| 9c8a093b2e | |||
| 1870d15c41 | |||
| 06cf9c012f | |||
| fdc415f545 | |||
| e7ff4b7a4e | |||
| 051acf5118 | |||
| a58a3c1bd7 | |||
| 0b04c9b827 | |||
| d05ec6c8fa | |||
| 04594df963 | |||
| dacfd65603 | |||
| 70d37d3555 | |||
| f01f26fd39 | |||
| ea86451686 | |||
| 27699192b4 | |||
| aff86db95e | |||
| bd2e645608 | |||
| 0a22484037 | |||
| aad65a2b4f | |||
| d93fbb0710 | |||
| ea8e435e64 | |||
| 10dcd6d4cc | |||
| b59f8a2850 | |||
| 00f645e001 | |||
| 897bbf54e2 | |||
| e97662314b | |||
| 910695474c | |||
| b91e4f0e28 | |||
| 5ea8f59e6f | |||
| aa71cdd20a | |||
| 64bd3296ab | |||
| c6a66e9a67 | |||
| c862bd6c09 | |||
| 2b5ce07554 | |||
| c0b9074b7e | |||
| 4f0ef3d12b | |||
| 21da15e284 | |||
| ead70c9315 | |||
| 2cfe1d13e7 | |||
| a40f90a6b7 | |||
| 738d48138a | |||
| f858c21c51 | |||
| df8ca2a6e7 | |||
| a029d38b7f | |||
| 9ba1049d7f | |||
| a2ee161cc6 | |||
| 20b170393e | |||
| aa67f7eadb | |||
| 7af39699dd | |||
| 694aefad30 | |||
| 1e908fba38 | |||
| 7acd8681b9 | |||
| 940f719345 | |||
| 76379938eb | |||
| 45ab9b6b1c | |||
| f77893bcc1 | |||
| 646c0586ab | |||
| 64bfbd51a0 | |||
| 31fc6633a2 | |||
| 85c5131dad | |||
| b13da26b8b | |||
| ad3271df7f | |||
| dfb49ee2a7 | |||
| a456701bde | |||
| 2b000c8d2f | |||
| cbd2cfcfbe | |||
| eb2e2e9f93 | |||
| 5dd26a09c0 | |||
| 040297fb2a | |||
| c89e5e5027 | |||
| 4e875b2f8a | |||
| a42b7e8ed3 | |||
| 69bbba9ff4 | |||
| 13f2912009 | |||
| 5d14691b7e | |||
| 7334f9c29e | |||
| 216ebc183c | |||
| 2305a0b7ac | |||
| b18c889777 | |||
| e952011de8 | |||
| cfb6196c2b | |||
| 8eb4f1b297 | |||
| c031b2aaf0 | |||
| 447523edfc | |||
| 1e4d3b4076 | |||
| a8ae54359a | |||
| 52de4d4fe8 | |||
| 6c404877f5 | |||
| bb939ccb36 | |||
| e43e249890 | |||
| 63fee3111a | |||
| af6bead88e | |||
| bc5651905e | |||
| 5b5743210b | |||
| 27218e871b | |||
| 0106f9c763 | |||
| 5dd4c4c6c6 | |||
| 525816a8fc | |||
| acba239b8a | |||
| d21eb06600 | |||
| 1cc8acd839 | |||
| 99ea97a160 | |||
| 77a0ba39ba | |||
| e584cf22f7 | |||
| c7bb7a264c | |||
| f84a04d29b | |||
| 31737baf75 | |||
| 9f18893149 | |||
| 7c20414229 | |||
| a33f7cc999 | |||
| b38991a7d5 | |||
| 94ef4cd4da | |||
| 09f6739686 | |||
| c3b5f44045 | |||
| b023763f63 | |||
| c24fb54c44 | |||
| 3f40bca286 | |||
| e9cc54bd73 | |||
| fd3e57e7bf | |||
| 66faf7a741 | |||
| ba28ae14f1 | |||
| 674c1a0e86 | |||
| aa754210a4 | |||
| 2fbdd5a16a | |||
| 09954d2370 | |||
| 8d34513ab7 | |||
| ed9a9aae7e | |||
| 48814c213a | |||
| 5a70b8a240 | |||
| 31ce74545a | |||
| 2b78581062 | |||
| 6d5aa0feff | |||
| 7cfbc6c344 | |||
| 64e7929714 | |||
| b2f817b639 | |||
| 7a3e5f4354 | |||
| f033a50632 | |||
| e3ace9f2fe | |||
| f07c2c797b | |||
| 1a8da9137a | |||
| 2476833a19 | |||
| 9f89d2101c | |||
| c3db5054b4 | |||
| f96562e2df | |||
| 22163950ef | |||
| c80d5cabcb | |||
| d3cfc9258f | |||
| 74ff9f04dd | |||
| 28f13836a1 | |||
| 242e70c9ec | |||
| 4bf2e0b376 | |||
| 144244bd5e | |||
| 473f8f6319 | |||
| b86ab4229f | |||
| 8f20589faa | |||
| 33c2000182 | |||
| 0505d265fe | |||
| f8e5a668f8 | |||
| 447ee2e82f | |||
| fe7730dbcc | |||
| 09ac804730 | |||
| 0f24623384 | |||
| 964c71382a | |||
| 65f8f67e4d | |||
| 8b92d42634 | |||
| 1cab95370b | |||
| b5fac676e9 | |||
| b67b0acc9a | |||
| c2fe4486d9 | |||
| 88fea6a8bd | |||
| 6b64aa0d89 | |||
| df3955cfe1 | |||
| 8359be7bee | |||
| 15f4a98ccb | |||
| 02e9654791 | |||
| 8fd570ad5c | |||
| bd10d6854a | |||
| 6249134dd6 | |||
| 5d425cadd6 | |||
| 695015b481 | |||
| 8d81b96692 | |||
| 0bc9aa612c | |||
| 968212e189 | |||
| 4ab2267867 | |||
| 2cfe39f276 | |||
| a155e1eeea | |||
| 81299f30de | |||
| 17349e8367 | |||
| f2faad3886 | |||
| 451a5923d4 | |||
| 5e3ed9ceed | |||
| 57f29c054a | |||
| be10df1c57 | |||
| baba2fad4d | |||
| bad4f505ef | |||
| 4390f02f4d | |||
| a790e0d4e0 | |||
| 3ccd983a5e | |||
| 760de9dead | |||
| b997b421fa | |||
| 208a66f303 | |||
| d431f4a434 | |||
| b571239312 | |||
| 126717ecdc | |||
| 374fc59499 | |||
| c8db3093be | |||
| e182799ca9 | |||
| 870f2614b5 | |||
| 0e88f40a99 | |||
| faead2ebd3 | |||
| 416098b53d | |||
| 73c756a780 | |||
| b7d261bad4 | |||
| c8203187a7 | |||
| 3014019692 | |||
| 9e2f60fa56 | |||
| 7642655ef0 | |||
| a28d199431 | |||
| cf387d1c0a | |||
| 64f31a234e | |||
| 09d1c513d6 | |||
| 89f703ae9b | |||
| ae09de6f71 | |||
| 7c39a0649a | |||
| 035d68a103 | |||
| 3b501e73c8 | |||
| d7353fc2a7 | |||
| c65579c0f6 | |||
| 0a886f0062 | |||
| a347e6dc97 | |||
| a9088c9626 | |||
| a19d61a16d | |||
| 003c8e724c | |||
| 053fc28371 | |||
| 9d8ae819a7 | |||
| 658b9f356c | |||
| becf9dd601 | |||
| b74ecbddf7 | |||
| 38e52bd821 | |||
| c3e08b2dc0 | |||
| f0019b540a | |||
| b0a72bf636 | |||
| 1701dd560e | |||
| 7ddb880843 | |||
| f0c929d743 | |||
| a7fc789056 | |||
| 11cf23acb8 | |||
| c05c271e1b | |||
| 9dd7ecbff1 | |||
| 96ec6f2c8e | |||
| ae707df709 | |||
| 4bbc7ad869 | |||
| 953dcf0034 | |||
| fb4bf1e372 | |||
| ff067d28e6 | |||
| 950e9afffb | |||
| ee15f64e55 | |||
| 7888e0490f | |||
| bf45f1a279 | |||
| 0299f03aa1 | |||
| 56d1ed9f10 | |||
| 855b8722de | |||
| aff99b5fa5 | |||
| d4683fb89d | |||
| b61855d00f | |||
| 3ee763fe29 | |||
| 03f7c51655 | |||
| 0c95468f93 | |||
| f7b96fb4b9 | |||
| 471f3e3f6f | |||
| 091bce7383 | |||
| e3cec34c8b | |||
| 4b82913308 | |||
| 0a50ead42a | |||
| 1428b33279 | |||
| 91bf6a6adf | |||
| cbafb8c77b | |||
| ec12281e9b | |||
| e65afd8b9d | |||
| 49d1411e47 | |||
| 7ba0b8106d | |||
| b7cfb904a2 | |||
| eed3a7cee7 | |||
| 014984cdd1 | |||
| c1f0a954bf | |||
| 5ca7841e61 | |||
| 54f8dd8912 | |||
| fc4cbf3c66 | |||
| b4cc8f85b7 | |||
| 4056178be4 | |||
| 2aab7b3fce | |||
| 9df978a4fd | |||
| d2a8e54ce0 | |||
| a1d8a894b3 | |||
| dc984fae1f | |||
| 1a3c9e0880 | |||
| 95debb0551 | |||
| f3e8377f34 | |||
| 00bd652752 | |||
| 214abd4110 | |||
| 22bdadc0db | |||
| 04a17af9ca | |||
| b90dc289f6 | |||
| 34ab553cd6 | |||
| 85c2c430ce | |||
| c8c24d9958 | |||
| 87035f1ee8 | |||
| d5b37ac423 | |||
| 02867d26db | |||
| eb146517fb | |||
| 5dd4e8f333 | |||
| 697a1ecb89 | |||
| 15b7fd564b | |||
| 0bee8b4fde | |||
| 64223e9fa5 | |||
| faf6df5de5 | |||
| 45496fa61a | |||
| f5a0495fa9 | |||
| 2461ba65c9 | |||
| a61de6a831 | |||
| d7e1a8d5a5 | |||
| 27fcba929e | |||
| dff50559bc | |||
| 62ca49816d | |||
| 127b2481e0 | |||
| 6c8b2235f1 | |||
| ef12ad5f2c | |||
| ab1da011e6 | |||
| eb643ba5f7 | |||
| ebc954e6d4 | |||
| a17aac9303 | |||
| 21a35a2daa | |||
| 0b8fb35809 | |||
| aa2555cb36 | |||
| 127393245d | |||
| 7868015ce6 | |||
| 7e40fe0eda | |||
| 16688b6932 | |||
| 3513f0da4b | |||
| c85fd32f0b | |||
| c8523799be | |||
| 050798f7a1 | |||
| bf4d479a07 | |||
| fb5a5c309b | |||
| 9dc5684a3e | |||
| 7083e2c90e | |||
| a2266afcdc | |||
| 16a4316854 | |||
| 727c3fecbb | |||
| 03f2a13dba | |||
| 068ff846e6 | |||
| 10923fdf05 | |||
| b1a2b3df8a | |||
| 7a8c433466 | |||
| 13410023e2 | |||
| 265fc4bafe | |||
| 46f5aea674 | |||
| 45e65c234a | |||
| eba923c929 | |||
| b7fe07a1ef | |||
| 19c4bc415e |
@@ -0,0 +1,3 @@
|
||||
# Docker dev volume
|
||||
container_data/
|
||||
data/
|
||||
@@ -1,27 +1,27 @@
|
||||
module.exports = {
|
||||
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
|
||||
}
|
||||
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,35 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ""
|
||||
labels: bug
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- It's harder to track bugs when multiple are reported in one issue. If you have more than one (unrelated) bugs to report, please make one issue per bug. -->
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Platform (please complete the following information):**
|
||||
|
||||
- OS: [e.g. Linux, Windows, Android, iOS]
|
||||
- Browser (latest version): [e.g. chrome, safari, firefox]
|
||||
- Watcharr Version: [e.g. v1.45.0]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ""
|
||||
labels: enhancement
|
||||
assignees: ""
|
||||
---
|
||||
|
||||
<!-- Feature requests can be hard to track when two or more unrelated ideas are included in one issue. Please make a new issue for each idea (make as many as you need to). -->
|
||||
|
||||
**Idea Overview**
|
||||
A quick overview of the idea you are proposing.
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Documentation
|
||||
about: Help us with our documentation. Is something important missing?
|
||||
title: ""
|
||||
labels: documentation
|
||||
assignees: IRHM
|
||||
---
|
||||
|
||||
<!-- Did you know: You can click `Edit this page` at the bottom of any page on the docs to propose exact changes? If this doesn't apply to you, continue below and fill out the issue. -->
|
||||
|
||||
**What is missing or needs updating on the documentation site?**
|
||||
Please describe in detail what is missing or needs updating on the documentation site.
|
||||
|
||||
**(optional) How would you write/update this doc?**
|
||||
How would you write out this documentation?
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
ghcr.io/${{ env.image }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Test PR (server)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
paths:
|
||||
- ".github/workflows/test-pr-server.yml"
|
||||
- "server/**"
|
||||
|
||||
jobs:
|
||||
format_check_go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
|
||||
- name: gofmt test
|
||||
run: |
|
||||
outp=$(gofmt -l .)
|
||||
if [ -n "$outp" ]; then
|
||||
echo "Need to fix formatting!"
|
||||
echo "$outp"
|
||||
exit 1
|
||||
else
|
||||
echo "Everything looks formatted"
|
||||
exit 0
|
||||
fi
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Test PR Changes
|
||||
name: Test PR (web)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -6,6 +6,14 @@ on:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
paths:
|
||||
- ".github/workflows/test-pr-web.yml"
|
||||
- "package*.json"
|
||||
- "svelte.config.json"
|
||||
- "vite.config.json"
|
||||
- "tsconfig.json"
|
||||
- ".prettier*"
|
||||
- "src/**"
|
||||
|
||||
jobs:
|
||||
linter_check:
|
||||
@@ -6,6 +6,10 @@ node_modules
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/.vscode
|
||||
|
||||
doc/.docusaurus/
|
||||
doc/build/
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 100,
|
||||
"useTabs": false,
|
||||
"trailingComma": "none",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
"tabWidth": 2,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"useTabs": true,
|
||||
"trailingComma": "all",
|
||||
"arrowParens": "always",
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +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}"
|
||||
}
|
||||
]
|
||||
// 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}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"editor.insertSpaces": false
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Attribution
|
||||
|
||||
We haven't rebuilt the wheel from scratch, here are the tools that have helped this project come to fruition.
|
||||
|
||||
(I am creating this file a bit late, some tools may be missing, hopefully over time I can get everything listed here).
|
||||
|
||||
## Web Client
|
||||
|
||||
Look at our [package.json](./package.json) for the packages we directly use.
|
||||
|
||||
- [Ionicons](https://ionic.io/ionicons) When looking for high quality icons, I go here first. Most of the icons in Watcharr are from here.
|
||||
- [Wikimedia Commons](https://commons.wikimedia.org) For [Streaming provider icons](https://commons.wikimedia.org/wiki/Category:SVG_logos_of_video_streaming_services).
|
||||
- [Penpot](https://github.com/penpot/penpot) For initial UI design and creating the logo.
|
||||
- [Google Fonts](https://fonts.google.com/) For custom fonts and for a few icons too. Don't worry though, fonts/icons are included in the Watcharr download, there are **no** requests made to google.
|
||||
|
||||
## Server
|
||||
|
||||
Look at our [go.mod](./server/go.mod) for the packages we use.
|
||||
|
||||
- [TMDB](https://developer.themoviedb.org) Movie/show data.
|
||||
- [IGDB](https://www.igdb.com/api) Game data.
|
||||
|
||||
## README.md
|
||||
|
||||
The tools I have used to create the projects README.
|
||||
|
||||
- [deviceframes](https://deviceframes.com)
|
||||
Created the devices mockup preview image with this tool (and GIMP).
|
||||
@@ -1,5 +1,7 @@
|
||||
#
|
||||
# Backend
|
||||
FROM golang:1.21-alpine AS server
|
||||
#
|
||||
FROM golang:1.22-alpine AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
@@ -12,7 +14,9 @@ RUN apk update && apk add --no-cache musl-dev gcc build-base
|
||||
# CGO_CFLAGS: https://github.com/mattn/go-sqlite3/issues/1164#issuecomment-1635253695
|
||||
RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURCE" go build -o ./watcharr
|
||||
|
||||
#
|
||||
# Frontend
|
||||
#
|
||||
FROM node:20-alpine AS ui
|
||||
|
||||
WORKDIR /app
|
||||
@@ -22,13 +26,19 @@ COPY ./static ./static
|
||||
|
||||
RUN npm install && npm run build
|
||||
|
||||
#
|
||||
# Production
|
||||
#
|
||||
FROM node:20-alpine AS runner
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
COPY --from=ui /app/build /ui
|
||||
COPY --from=ui /app/package.json /app/package-lock.json /ui
|
||||
RUN cd /ui && npm ci --omit=dev
|
||||
|
||||
# Install just the prod dependencies for final step.
|
||||
# We --ignore-scripts, to stop the `prepare` script from auto-
|
||||
# running, which is only meant for development (will error).
|
||||
RUN cd /ui && npm ci --omit=dev --ignore-scripts=true
|
||||
|
||||
EXPOSE 3080
|
||||
|
||||
|
||||
@@ -18,43 +18,60 @@ 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://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
|
||||
[Track Progress Until Next Version](https://github.com/orgs/sbondCo/projects/9/views/3)
|
||||
[Track progress for the next version](https://github.com/orgs/sbondCo/projects/9/views/3).
|
||||
|
||||
### Contents
|
||||
|
||||
- [Screenshots](#screenshots)
|
||||
- [Setup](#set-up)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Set Up](#set-up)
|
||||
- [Community Made Tools](#community-made-tools)
|
||||
- [Getting Help](#getting-help)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
# Screenshots
|
||||
|
||||
<p align="center">
|
||||
|
||||
| Homepage | Watched Show Hover |
|
||||
| ---------------------------------------------------------- | ----------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage.png" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png" alt="Watched List" /> |
|
||||
<img src="./screenshot/devices-mock.png" alt="Overview" />
|
||||
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| --------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ |
|
||||
| <img src="./screenshot/homepage-poster-change-status.png" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png" alt="Content Details Page" /> |
|
||||
| Homepage | Watched Show Hover |
|
||||
| -------------------------------------------------------------- | --------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage.png?v=2" alt="Watched List" /> | <img src="./screenshot/homepage-poster-hover.png?v=2" alt="Watched List" /> |
|
||||
|
||||
| User Profile | Discover |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------------------------------- |
|
||||
| <img src="./screenshot/user-profile.png" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png" alt="Discover Page" /> |
|
||||
| Watched Show Status Change | Movie Details |
|
||||
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-poster-change-status.png?v=2" alt="Changing Show Status" /> | <img src="./screenshot/content-details-page.png?v=2" alt="Content Details Page" /> |
|
||||
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/homepage-dark.png" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png" alt="Dark Theme Content Page" /> |
|
||||
| User Profile | Discover |
|
||||
| ----------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
||||
| <img src="./screenshot/user-profile.png?v=2" alt="User Profile Page" /> | <img src="./screenshot/discover-page.png?v=2" alt="Discover Page" /> |
|
||||
|
||||
| Dark Homepage | Dark Content Details |
|
||||
| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| <img src="./screenshot/homepage-dark.png?v=2" alt="Dark Theme Homepage" /> | <img src="./screenshot/content-details-page-dark.png?v=2" alt="Dark Theme Content Page" /> |
|
||||
|
||||
</p>
|
||||
|
||||
# Set Up
|
||||
|
||||
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [docker-compose.yml](./docker-compose.yml) file is your friend.
|
||||
[Checkout our documentation](https://watcharr.app/docs/category/installation) for an up to date guide on setup! If you hate manuals, but love docker, this [compose.yml](./compose.yml) file is your friend.
|
||||
|
||||
# Community Made Tools
|
||||
|
||||
Third-party tools made by the community for enhancing your Watcharr experience!
|
||||
|
||||
- [Kodi Plugin](https://github.com/airdogvan/watcharr_kodi) by [airdogvan](https://github.com/airdogvan) for automatically tracking your watched shows/movies.
|
||||
|
||||
Thanks to anyone that has made a script or tool for Watcharr. Feel free to add your own to the list if you have one!
|
||||
|
||||
**Note:** I cannot provide any assurances for these tools or stay on top of them (code review, etc), if you have any problems please open an issue in the project for the tool so that they can stay organized.
|
||||
|
||||
# Getting Help
|
||||
|
||||
If something isn't working for you or you are stuck, [creating an issue](https://github.com/sbondCo/Watcharr/issues/new) is the best way to get help! Every type of issue is accepted, so don't be afraid to ask anything!
|
||||
|
||||
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
|
||||
|
||||
# Contributing
|
||||
|
||||
Please continue to our [contributing guide](CONTRIBUTING.md).
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues.**
|
||||
|
||||
You can reach out directly to [@irhm:matrix.org](https://matrix.to/#/@irhm:matrix.org) on matrix to discuss the vulnerability or you may [draft a new security advisory on this repository](https://github.com/sbondCo/Watcharr/security/advisories/new).
|
||||
@@ -0,0 +1,24 @@
|
||||
# This compose file will replace watcharrs
|
||||
# built in UI with a custom one provided in
|
||||
# a named volume (for use with ui-build image).
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# We dont need to build here too for testing if
|
||||
# a custom ui works, so using latest prod image.
|
||||
image: ghcr.io/sbondco/watcharr:latest
|
||||
container_name: watcharr
|
||||
ports:
|
||||
- 3080:3080
|
||||
volumes:
|
||||
- ./container_data:/data
|
||||
- type: volume
|
||||
source: watcharr-ui
|
||||
target: /ui
|
||||
volume:
|
||||
nocopy: true
|
||||
subpath: build
|
||||
|
||||
volumes:
|
||||
watcharr-ui:
|
||||
external: true
|
||||
@@ -1,4 +1,6 @@
|
||||
# Website
|
||||
# Watcharr Docs
|
||||
|
||||
View the live site at [watcharr.app](https://watcharr.app/).
|
||||
|
||||
This website is built using [Docusaurus 2](https://docusaurus.io/), a modern static website generator.
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")]
|
||||
presets: [require.resolve("@docusaurus/core/lib/babel/preset")],
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Importing",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides for importing your data from other services."
|
||||
}
|
||||
"label": "Importing",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides for importing your data from other services."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Choose how you would like to install Watcharr."
|
||||
}
|
||||
"label": "Installation",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Choose how you would like to install Watcharr."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
description: Install and setup with Docker Compose for access through a subpath.
|
||||
---
|
||||
|
||||
# Docker Compose (subpath)
|
||||
|
||||
Install and setup with Docker Compose for access through a subpath.
|
||||
|
||||
:::info Not a great experience at the moment!
|
||||
|
||||
Currently, hosting Watcharr via a subpath on your server is not very easy (unlike hosting under a subdomain). Hopefully this will change in the future, but as a temporary measure to at least allow hosting under a subpath, this method has been provided.
|
||||
|
||||
The following issue will continue to track this: https://github.com/sbondCo/Watcharr/issues/312
|
||||
|
||||
:::
|
||||
|
||||
## Installing
|
||||
|
||||
### Build UI
|
||||
|
||||
First we have to build the frontend with our subpath provided as an environment variable.
|
||||
|
||||
If you don't want to use `/watcharr` as your subpath, replace the value of `WATCHARR_BASE` before running the command.
|
||||
|
||||
```bash
|
||||
docker run -e WATCHARR_BASE=/watcharr -v watcharr-ui:/ui --rm ghcr.io/sbondco/watcharr-ui-build:latest
|
||||
```
|
||||
|
||||
Once the container has finished running the build script, it will exit and remove itself. The `watcharr-ui` volume will contain the built files.
|
||||
|
||||
### Install Watcharr
|
||||
|
||||
Now we can install Watcharr. You can copy the example below to get started:
|
||||
|
||||
```yaml title="compose.yml"
|
||||
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
|
||||
# Use our volume containing built ui files
|
||||
# instead of default ui included in image.
|
||||
- type: volume
|
||||
source: watcharr-ui
|
||||
target: /ui
|
||||
volume:
|
||||
nocopy: true
|
||||
subpath: build
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
watcharr-ui:
|
||||
external: true
|
||||
```
|
||||
|
||||
:::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
|
||||
|
||||
:::
|
||||
|
||||
1. Update your built ui files by following the [Build UI](#build-ui) step again.
|
||||
|
||||
2. Update the `image` version in your `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
|
||||
```
|
||||
|
||||
3. Pull the new changes and re-create your container:
|
||||
|
||||
```bash
|
||||
docker compose pull && docker compose down && docker compose up -d
|
||||
```
|
||||
|
||||
And that is it!
|
||||
@@ -9,7 +9,7 @@ description: Install and setup with Docker Compose.
|
||||
|
||||
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
```yaml title="compose.yml"
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
@@ -51,7 +51,7 @@ Breaking changes are marked at the top of releases: https://github.com/sbondCo/W
|
||||
|
||||
Updating your server can be done in two steps:
|
||||
|
||||
1. Update the `image` version in your `docker-compose.yml` file.
|
||||
1. Update the `image` version in your `compose.yml` file.
|
||||
Skip this step if you are using the `latest` tag.
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
"label": "Server Configuration",
|
||||
"position": 2,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Learn how to configure your server."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
sidebar_position: 100
|
||||
description: Backing up your server.
|
||||
---
|
||||
|
||||
# Backing Up
|
||||
|
||||
Watcharr cannot handle backing up for you, but its simple structure should make it easy to slide it into your existing backup routine.
|
||||
|
||||
Once you have created your first backup, it's recommended that you try restoring from that backup to make sure everything is correct and will work in case you need to restore from it in the future.
|
||||
|
||||
:::danger Shutdown your server!
|
||||
Always shutdown Watcharr before backing up its data, otherwise you run the risk of corrupting files (mainly your database).
|
||||
:::
|
||||
|
||||
## Simple Steps
|
||||
|
||||
:::success Recommended
|
||||
This is the recommended way to backup.
|
||||
:::
|
||||
|
||||
Here are the steps that are run daily for the `beta.watcharr.app` instance.
|
||||
|
||||
1. Shutdown Watcharr.
|
||||
2. Copy and backup the entire `data` folder (default location is `./data`, backup whichever folder your server is configured to store its data in).
|
||||
3. Start Watcharr.
|
||||
|
||||
I call these the simple steps because they simply backup your entire server, if you ever need to restore your server, it will start up from its exact state at backup.
|
||||
|
||||
## Advanced Steps
|
||||
|
||||
:::danger Discouraged
|
||||
Backing up this way is discouraged because new important files could be added later and missed.
|
||||
:::
|
||||
|
||||
If you don't care about the warning not to backup this way, here are the "important" files that you can single out for backup:
|
||||
|
||||
- `watcharr.db` Your database, holds all users, their watchlists, etc.
|
||||
- `watcharr.json` Server config.
|
||||
- `img/up` Profile picture uploads.
|
||||
- `img/games` Game posters (not exactly important, but scenarios in which this folder is not backed up have not been tested, only relevant for servers with game support enabled).
|
||||
@@ -1,8 +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."
|
||||
}
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,104 +7,101 @@ const darkTheme = 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",
|
||||
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: "/",
|
||||
// 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.
|
||||
// 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",
|
||||
onBrokenLinks: "throw",
|
||||
onBrokenMarkdownLinks: "warn",
|
||||
|
||||
trailingSlash: false,
|
||||
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"]
|
||||
},
|
||||
// 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: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css")
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
],
|
||||
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: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
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://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right"
|
||||
}
|
||||
]
|
||||
},
|
||||
prism: {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true
|
||||
}
|
||||
})
|
||||
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",
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
position: "right",
|
||||
},
|
||||
],
|
||||
},
|
||||
prism: {
|
||||
theme: lightTheme,
|
||||
darkTheme: darkTheme,
|
||||
},
|
||||
colorMode: {
|
||||
defaultMode: "light",
|
||||
// Dark theme currently disabled.. no time to fix the icons etc.
|
||||
disableSwitch: true,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
{
|
||||
"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": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"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": ">=18.0"
|
||||
}
|
||||
"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": "3.1.1",
|
||||
"@docusaurus/preset-classic": "3.1.1",
|
||||
"@mdx-js/react": "^3.0.0",
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
},
|
||||
"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": ">=18.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
|
||||
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
|
||||
const sidebars = {
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }]
|
||||
// By default, Docusaurus generates a sidebar from the docs folder structure
|
||||
tutorialSidebar: [{ type: "autogenerated", dirName: "." }],
|
||||
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
// But you can create a sidebar manually
|
||||
/*
|
||||
tutorialSidebar: [
|
||||
'intro',
|
||||
'hello',
|
||||
|
||||
@@ -3,59 +3,62 @@ 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.</>
|
||||
)
|
||||
}
|
||||
{
|
||||
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>
|
||||
);
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<section className={styles.features}>
|
||||
<div className="container">
|
||||
<div className="row">
|
||||
{FeatureList.map((props, idx) => (
|
||||
<Feature key={idx} {...props} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
.features {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 2rem 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featureSvg {
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
@@ -4,68 +4,68 @@ import clsx from "clsx";
|
||||
import Viewer from "viewerjs";
|
||||
|
||||
function TwoColFeat({ title, description, imgSrc }) {
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageTwoColFeats() {
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer"
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2,
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer",
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png?v=2"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png?v=2"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +1,53 @@
|
||||
.twoColFeats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
}
|
||||
|
||||
.twoCol {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
}
|
||||
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 850px) {
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,39 +6,39 @@
|
||||
|
||||
/* 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);
|
||||
--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);
|
||||
--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);
|
||||
}
|
||||
|
||||
.twoColViewer .viewer-close {
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Stupid poopin fix for viewerjs because whatever is bundling the css
|
||||
isn't keeping the order of this correctly. All this time wasted and it
|
||||
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
|
||||
.viewer-in {
|
||||
opacity: 1 !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@@ -10,44 +10,47 @@ import clsx from "clsx";
|
||||
import { HomepageTwoColFeats } from "../components/TwoColFeat";
|
||||
|
||||
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>
|
||||
);
|
||||
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 />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
return (
|
||||
<Layout
|
||||
title={`${siteConfig.title}`}
|
||||
description="Description will go into a meta tag in <head />"
|
||||
>
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,31 +4,31 @@
|
||||
*/
|
||||
|
||||
.heroContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
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;
|
||||
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;
|
||||
width: 30vh;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
This folder is for any _extra_ images that we produce.
|
||||
|
||||
The main watcharr Dockerfile remains in project root.
|
||||
@@ -0,0 +1,16 @@
|
||||
#
|
||||
# Frontend
|
||||
#
|
||||
FROM node:20-alpine AS ui
|
||||
|
||||
COPY ./docker/ui-build/entrypoint.sh /entrypoint.sh
|
||||
RUN ["chmod", "+x", "/entrypoint.sh"]
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
|
||||
COPY ./src ./src
|
||||
COPY ./static ./static
|
||||
|
||||
VOLUME /ui
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
@@ -0,0 +1,7 @@
|
||||
Image for building watcharr ui.
|
||||
|
||||
Accepts environment variables:
|
||||
|
||||
- `WATCHARR_BASE`
|
||||
|
||||
https://watcharr.app/docs/installation/docker-compose-subpath
|
||||
@@ -0,0 +1,15 @@
|
||||
# To test prod
|
||||
services:
|
||||
watcharr-ui-build:
|
||||
build:
|
||||
context: ../../
|
||||
dockerfile: ./docker/ui-build/Dockerfile
|
||||
container_name: watcharr-ui-build
|
||||
volumes:
|
||||
- watcharr-ui:/ui
|
||||
environment:
|
||||
WATCHARR_BASE: "/sp"
|
||||
|
||||
volumes:
|
||||
watcharr-ui:
|
||||
name: "watcharr-ui"
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
|
||||
# This is loosely based on the main containers Dockerfile commands.
|
||||
# Just everything needed to get ui built and ready and nothing else.
|
||||
|
||||
set -x
|
||||
|
||||
echo "wuib: Starting build"
|
||||
|
||||
# Install all deps
|
||||
/usr/local/bin/npm install
|
||||
|
||||
# Run build
|
||||
/usr/local/bin/npm run build
|
||||
|
||||
# Remove any existing build files from volume
|
||||
rm -rf /ui/build
|
||||
|
||||
# Move build folder to /ui
|
||||
mv /app/build /ui
|
||||
# Move package.json and lock file to /ui/build for final npm ci call
|
||||
mv /app/package.json /app/package-lock.json /ui/build
|
||||
|
||||
# cd to build files final destination and install dependencies for production
|
||||
cd /ui/build && /usr/local/bin/npm ci --omit=dev --ignore-scripts=true
|
||||
@@ -1,41 +1,42 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.38.2",
|
||||
"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": "^5.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.4.0",
|
||||
"@typescript-eslint/parser": "^7.6.0",
|
||||
"@vite-pwa/sveltekit": "^0.3.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.35.1",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-svelte": "^3.2.2",
|
||||
"svelte": "^4.2.12",
|
||||
"svelte-check": "^3.6.9",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.8"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.8",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
"name": "watcharr",
|
||||
"version": "2.0.2",
|
||||
"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",
|
||||
"prepare": "svelte-kit sync",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@typescript-eslint/eslint-plugin": "^8.18.2",
|
||||
"@typescript-eslint/parser": "^8.18.2",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"sass": "^1.83.0",
|
||||
"svelte": "^5.17.3",
|
||||
"svelte-check": "^4.1.3",
|
||||
"svelte-eslint-parser": "^0.42.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.7"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.7.4",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 488 KiB |
|
Before Width: | Height: | Size: 305 KiB After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 661 KiB After Width: | Height: | Size: 900 KiB |
|
Before Width: | Height: | Size: 820 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 743 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 752 KiB After Width: | Height: | Size: 1.0 MiB |
|
Before Width: | Height: | Size: 816 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -0,0 +1 @@
|
||||
These screenshots were first uploaded Dec 10, 2023. They were retired on Jan 19, 2025.
|
||||
|
After Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 305 KiB |
|
After Width: | Height: | Size: 661 KiB |
|
After Width: | Height: | Size: 820 KiB |
|
After Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 752 KiB |
|
After Width: | Height: | Size: 816 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 75 KiB |
@@ -10,11 +10,13 @@ import (
|
||||
|
||||
type ActivityType string
|
||||
|
||||
// _AUTO activities are for when logic updates something for the user (automations basically).
|
||||
var (
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
@@ -25,11 +27,13 @@ var (
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_AUTO ActivityType = "SEASON_ADDED_AUTO"
|
||||
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
|
||||
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
SEASON_STATUS_CHANGED_AUTO ActivityType = "SEASON_STATUS_CHANGED_AUTO"
|
||||
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
|
||||
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
|
||||
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -27,9 +26,11 @@ import (
|
||||
type UserType uint8
|
||||
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
WATCHARR_USER UserType = 0
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
// Registered via trusted header auth
|
||||
PROXY_USER UserType = 3
|
||||
)
|
||||
|
||||
// User Perms
|
||||
@@ -62,7 +63,10 @@ type User struct {
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All Tags
|
||||
Tags []Tag `json:"-"`
|
||||
// Users permissions
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
@@ -91,6 +95,13 @@ type UserSettings struct {
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
// User's country to get correct content streaming providers.
|
||||
Country *string `gorm:"default:'US'" json:"country"`
|
||||
// Does the user want show, season and episode automations enabled.
|
||||
AutomateShowStatuses *bool `gorm:"default:true" json:"automateShowStatuses"`
|
||||
// Rating system user wants to use (frontend only).
|
||||
// RatingSystem enum in frontend maxes out at 3, so just max=3 on this and we should be gut.
|
||||
RatingSystem *int `json:"ratingSystem" binding:"omitempty,max=3"`
|
||||
// Rating step for supported rating systems (frontend only, enum goes up to 2).
|
||||
RatingStep *int `json:"ratingStep" binding:"omitempty,max=2"`
|
||||
}
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
@@ -150,6 +161,8 @@ type AvailableAuthProvidersResponse struct {
|
||||
AvailableAuthProviders []string `json:"available"`
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
UseEmby bool `json:"useEmby"`
|
||||
HeaderAuthAutoLogin bool `json:"headerAuthAutoLogin"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
@@ -196,7 +209,7 @@ func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
}
|
||||
// Parse token
|
||||
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(os.Getenv("JWT_SECRET")), nil
|
||||
return []byte(Config.JWT_SECRET), nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("AuthRequired failed to parse token", "error", err)
|
||||
@@ -576,7 +589,7 @@ func signJWT(user *User) (token string, err error) {
|
||||
})
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
return jwt.SignedString([]byte(os.Getenv("JWT_SECRET")))
|
||||
return jwt.SignedString([]byte(Config.JWT_SECRET))
|
||||
}
|
||||
|
||||
func hashPassword(password string, p *ArgonParams) (encodedHash string, err error) {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// auth_proxy contains the logic for Trusted Header Authentication/SSO.
|
||||
//
|
||||
// This code is inherently dangerous since we are implicitly trusting
|
||||
// a header for auth, so this should only be configured if you are
|
||||
// certain your watcharr instance is only available behind your proxy.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TrustedHeaderAuthSetting struct {
|
||||
// Required: Should header auth be enabled?
|
||||
// This bool exists so header auth can be toggled
|
||||
// easily without having to remove configuration.
|
||||
// To be actually enabled, HEADER_NAME must also
|
||||
// be set.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Required: What is the name of the trusted header
|
||||
// that will contain the logged in users username?
|
||||
HeaderName string `json:"headerName"`
|
||||
// Should the frontend attempt auto login if
|
||||
// trusted header auth is enabled.
|
||||
AutoLogin bool `json:"autoLogin"`
|
||||
// Where can we redirect the user to logout
|
||||
// of the auth service?
|
||||
LogoutUrl string `json:"logoutUrl"`
|
||||
}
|
||||
|
||||
type TrustedHeaderAuthLogoutDetailsResponse struct {
|
||||
LogoutUrl string `json:"logoutUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Is trusted header auth configured on this server?
|
||||
func trustedHeaderAuthIsEnabled() bool {
|
||||
return Config.HEADER_AUTH.Enabled && Config.HEADER_AUTH.HeaderName != ""
|
||||
}
|
||||
|
||||
func setTrustedHeaderAuthSetting(has TrustedHeaderAuthSetting) error {
|
||||
slog.Debug("setTrustedHeaderAuthSetting: Attempting to update to new provided value", "new_value", has)
|
||||
Config.HEADER_AUTH = has
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("setTrustedHeaderAuthSetting: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets proxy logout details.
|
||||
// Details are accessible to any user for the logout flow.
|
||||
// If proxy configured should be checked before using this.
|
||||
func getTrustedHeaderAuthLogoutDetails() *TrustedHeaderAuthLogoutDetailsResponse {
|
||||
return &TrustedHeaderAuthLogoutDetailsResponse{
|
||||
LogoutUrl: Config.HEADER_AUTH.LogoutUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Login via header sso
|
||||
func loginTrustedHeaderAuth(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Debug("loginTrustedHeaderAuth: A user is logging in", "username_from_header", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND type = ?", user.Username, PROXY_USER).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Info("loginTrustedHeaderAuth: Creating new User from authentication header", "username_from_header", user.Username)
|
||||
// Record not found, so we should create the user (if configured to do so)
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.Username = user.Username
|
||||
dbUser.Type = PROXY_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
res = db.Create(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to create new user in db", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("failed to create new user")
|
||||
}
|
||||
} else {
|
||||
slog.Error("loginTrustedHeaderAuth: An error occurred when looking up user in db", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
@@ -34,6 +34,9 @@ type ServerConfig struct {
|
||||
// to enable it as an auth provider.
|
||||
JELLYFIN_HOST string `json:",omitempty"`
|
||||
|
||||
// Optional: Use Emby instead of Jellyfin branding in the ui.
|
||||
USE_EMBY bool
|
||||
|
||||
// Enable/disable signup functionality.
|
||||
// Set to `false` to disable registering an account.
|
||||
SIGNUP_ENABLED bool
|
||||
@@ -51,10 +54,17 @@ type ServerConfig struct {
|
||||
// Will be fetched automatically when PLEX_HOST is provided via web ui.
|
||||
PLEX_MACHINE_ID string `json:",omitempty"`
|
||||
|
||||
// Optional: Trusted header authentication configuration.
|
||||
// VERY DANGEROUS if access is not controlled correctly!
|
||||
HEADER_AUTH TrustedHeaderAuthSetting `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
|
||||
// Optional: Schedule for tasks.
|
||||
TASK_SCHEDULE map[string]int `json:",omitempty"`
|
||||
|
||||
// Enable/disable debug logging. Useful for when trying
|
||||
// to figure out exactly what the server is doing at a point
|
||||
// of failure.
|
||||
@@ -73,6 +83,7 @@ func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
DEFAULT_COUNTRY: c.DEFAULT_COUNTRY,
|
||||
JELLYFIN_HOST: c.JELLYFIN_HOST,
|
||||
USE_EMBY: c.USE_EMBY,
|
||||
TMDB_KEY: c.TMDB_KEY,
|
||||
PLEX_HOST: c.PLEX_HOST,
|
||||
PLEX_MACHINE_ID: c.PLEX_MACHINE_ID,
|
||||
@@ -86,6 +97,35 @@ func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
type ServerConfigGetByName struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
// Get config item by name.
|
||||
func (c *ServerConfig) Get(s string) (ServerConfigGetByName, error) {
|
||||
switch s {
|
||||
case "DEFAULT_COUNTRY":
|
||||
return ServerConfigGetByName{Value: c.DEFAULT_COUNTRY}, nil
|
||||
case "JELLYFIN_HOST":
|
||||
return ServerConfigGetByName{Value: c.JELLYFIN_HOST}, nil
|
||||
case "USE_EMBY":
|
||||
return ServerConfigGetByName{Value: c.USE_EMBY}, nil
|
||||
case "SIGNUP_ENABLED":
|
||||
return ServerConfigGetByName{Value: c.SIGNUP_ENABLED}, nil
|
||||
case "TMDB_KEY":
|
||||
return ServerConfigGetByName{Value: c.TMDB_KEY}, nil
|
||||
case "PLEX_HOST":
|
||||
return ServerConfigGetByName{Value: c.PLEX_HOST}, nil
|
||||
case "PLEX_MACHINE_ID":
|
||||
return ServerConfigGetByName{Value: c.PLEX_MACHINE_ID}, nil
|
||||
case "HEADER_AUTH":
|
||||
return ServerConfigGetByName{Value: c.HEADER_AUTH}, nil
|
||||
case "DEBUG":
|
||||
return ServerConfigGetByName{Value: c.DEBUG}, nil
|
||||
}
|
||||
return ServerConfigGetByName{}, errors.New("invalid setting")
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
@@ -150,6 +190,8 @@ func updateConfig(k string, v any) error {
|
||||
}
|
||||
if k == "JELLYFIN_HOST" {
|
||||
Config.JELLYFIN_HOST = v.(string)
|
||||
} else if k == "USE_EMBY" {
|
||||
Config.USE_EMBY = v.(bool)
|
||||
} else if k == "SIGNUP_ENABLED" {
|
||||
Config.SIGNUP_ENABLED = v.(bool)
|
||||
} else if k == "TMDB_KEY" {
|
||||
@@ -162,7 +204,12 @@ func updateConfig(k string, v any) error {
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
return writeConfig()
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("updateConfig: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -17,8 +18,12 @@ type ContentType string
|
||||
const (
|
||||
MOVIE ContentType = "movie"
|
||||
SHOW ContentType = "tv"
|
||||
// Show episode
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
@@ -87,7 +92,7 @@ func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath))
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath), false)
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
@@ -240,9 +245,12 @@ func transformProviders(c *interface{}, country string) {
|
||||
}
|
||||
}
|
||||
|
||||
func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBSearchMultiResponse)
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
@@ -250,6 +258,81 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
resp := new(TMDBSearchMoviesResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie search request!", "error", err.Error())
|
||||
return TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
resp := new(TMDBSearchShowsResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv search request!", "error", err.Error())
|
||||
return TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
|
||||
resp := new(TMDBSearchPeopleResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete people search request!", "error", err.Error())
|
||||
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "person"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Search for content by an external id (imdb, etc).
|
||||
// Defaults to imdb if no source if provided (probably most common).
|
||||
func searchByExternalId(id string, source string) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBFindByExternalIdResponse)
|
||||
if source == "" {
|
||||
source = "imdb"
|
||||
}
|
||||
err := tmdbRequest("/find/"+id, map[string]string{"external_source": source + "_id"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete find/external_id request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete find/external_id request")
|
||||
}
|
||||
comb := []TMDBSearchMultiResults{}
|
||||
comb = append(comb, resp.MovieResults...)
|
||||
comb = append(comb, resp.TvResults...)
|
||||
comb = append(comb, resp.PersonResults...)
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return TMDBSearchMultiResponse{TMDBSearchResponse: TMDBSearchResponse[TMDBSearchMultiResults]{
|
||||
Results: comb,
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
@@ -294,13 +377,26 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
var cacheKey = "contentstore-seasondetails-" + tvId + "-" + seasonNumber
|
||||
resp := new(TMDBSeasonDetails)
|
||||
if err := ContentStore.Get(cacheKey, &resp); err != nil {
|
||||
if err != persistence.ErrCacheMiss {
|
||||
slog.Error("seasonDetails: Cache failed for some reason", "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("seasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete season details request!", "error", err.Error())
|
||||
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
if err := ContentStore.Set(cacheKey, resp, time.Hour*24); err != nil {
|
||||
slog.Error("seasonDetails: Failed to set cache!", "error", err)
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ type FollowThoughts struct {
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating float64 `json:"rating"`
|
||||
}
|
||||
|
||||
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
|
||||
|
||||
@@ -33,6 +33,8 @@ func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp i
|
||||
return errors.New("using igdbHost without a clientID or accessToken")
|
||||
}
|
||||
|
||||
slog.Debug("IGDB->req: Creating a request.", "ep", ep, "body", b)
|
||||
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
@@ -181,6 +183,24 @@ func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Should return same details as `Search`, we both are for search page only minimal details required.
|
||||
func (i *IGDB) SearchById(id string) (GameSearchResponse, error) {
|
||||
slog.Debug("IGDB Search called", "id", id)
|
||||
var resp GameSearchResponse
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields name, cover.image_id, version_title, summary, first_release_date; where id = "+id+";",
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("IGDB Search request failed!", "error", err)
|
||||
return GameSearchResponse{}, errors.New("request failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (i *IGDB) GameDetails(id string) (GameDetailsResponse, error) {
|
||||
slog.Debug("IGDB GameDetails called", "id", id)
|
||||
var resp []GameDetailsResponse
|
||||
|
||||
@@ -38,7 +38,7 @@ type Game struct {
|
||||
|
||||
type PlayedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,52 +1,56 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.21
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/gin-contrib/cache v1.2.0
|
||||
github.com/gin-contrib/cors v1.7.1
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/gin-contrib/cache v1.3.1
|
||||
github.com/gin-contrib/cors v1.7.3
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.14.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.22.0
|
||||
golang.org/x/crypto v0.32.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.5.5
|
||||
gorm.io/gorm v1.25.9
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.11.3 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/bytedance/sonic v1.12.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.19.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/gomodule/redigo v1.8.9 // indirect
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/go-playground/validator/v10 v10.23.0 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||
github.com/memcachier/mc/v3 v3.0.3 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.7.0 // indirect
|
||||
golang.org/x/net v0.22.0 // indirect
|
||||
golang.org/x/sys v0.19.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.33.0 // indirect
|
||||
golang.org/x/arch v0.12.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sys v0.29.0 // indirect
|
||||
golang.org/x/text v0.21.0 // indirect
|
||||
google.golang.org/protobuf v1.36.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,59 +2,62 @@ github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8b
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.11.3 h1:jRN+yEjakWh8aK5FzrciUHG8OFXK+4/KrAX/ysEtHAA=
|
||||
github.com/bytedance/sonic v1.11.3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/bytedance/sonic v1.12.6 h1:/isNmCUF2x3Sh8RAp/4mh4ZGkcFAX/hLrzrK3AvpRzk=
|
||||
github.com/bytedance/sonic v1.12.6/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
|
||||
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/cache v1.2.0 h1:WA+AJR4kmHDTaLLShCHo/IeWVmmGRZ3Lsr3JQ46tFlE=
|
||||
github.com/gin-contrib/cache v1.2.0/go.mod h1:2KkFL8PSnPF3Tt5E2Jpc3HWuBAUKqGZnClCFMm0tXQI=
|
||||
github.com/gin-contrib/cors v1.7.1 h1:s9SIppU/rk8enVvkzwiC2VK3UZ/0NNGsWfUKvV55rqs=
|
||||
github.com/gin-contrib/cors v1.7.1/go.mod h1:n/Zj7B4xyrgk/cX1WCX2dkzFfaNm/xJb6oIUk7WTtps=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7 h1:SKFKl7kD0RiPdbht0s7hFtjl489WcQ1VyPW8ZzUMYCA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.7/go.mod h1:GDlAgAyIRT27BhFl53XNAFtfjzOkLaF35JdEG0P7LtU=
|
||||
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
|
||||
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
|
||||
github.com/gin-contrib/cors v1.7.3 h1:hV+a5xp8hwJoTw7OY+a70FsL8JkVVFTXw9EcfrYUdns=
|
||||
github.com/gin-contrib/cors v1.7.3/go.mod h1:M3bcKZhxzsvI+rlRSkkxHyljJt1ESd93COUvemZ79j4=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-co-op/gocron/v2 v2.14.2 h1:S6CbI7MVfD3S/aPJNLoSg2YcGyEqzEMwUopDejuT4Oc=
|
||||
github.com/go-co-op/gocron/v2 v2.14.2/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.19.0 h1:ol+5Fu+cSq9JD7SoSqe04GMI92cbn0+wvQ3bZ8b/AU4=
|
||||
github.com/go-playground/validator/v10 v10.19.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o=
|
||||
github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
|
||||
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/jonboulle/clockwork v0.4.0 h1:p4Cf1aMWXnXAUh8lVfewRBx1zaTSYKrKMF2g3ST4RZ4=
|
||||
github.com/jonboulle/clockwork v0.4.0/go.mod h1:xgRqUGwRcjKCO1vbZUEtSLrqKoPSsUpK7fnezOII0kc=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
@@ -64,8 +67,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/memcachier/mc/v3 v3.0.3 h1:qii+lDiPKi36O4Xg+HVKwHu6Oq+Gt17b+uEiA0Drwv4=
|
||||
github.com/memcachier/mc/v3 v3.0.3/go.mod h1:GzjocBahcXPxt2cmqzknrgqCOmMxiSzhVKPOe90Tpug=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -73,10 +76,12 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0 h1:QLgLl2yMN7N+ruc31VynXs1vhMZa7CeHHejIeBAsoHo=
|
||||
github.com/pelletier/go-toml/v2 v2.2.0/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62/go.mod h1:65XQgovT59RWatovFwnwocoUxiI/eENTnOY5GK3STuY=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
@@ -84,34 +89,34 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.7.0 h1:pskyeJh/3AmoQ8CPE95vxHLqp1G1GfGNXTmcl9NEKTc=
|
||||
golang.org/x/arch v0.7.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
|
||||
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
|
||||
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
|
||||
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
|
||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -120,9 +125,8 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -30,7 +30,7 @@ type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
@@ -38,6 +38,8 @@ type ImportRequest struct {
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
|
||||
Tags []TagAddRequest `json:"tags"`
|
||||
ImdbID string `json:"imdbId"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -70,7 +72,53 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
return successfulImport(db, userId, cr.ID, SHOW, ar)
|
||||
}
|
||||
}
|
||||
sr, err := searchContent(ar.Name)
|
||||
// If imdb id passed, attempt to get content with it
|
||||
if ar.ImdbID != "" && (ar.Type == MOVIE || ar.Type == SHOW || ar.Type == SHOW_EPISODE) {
|
||||
if imdbResp, err := searchByExternalId(ar.ImdbID, "imdb"); err == nil {
|
||||
if len(imdbResp.Results) == 1 {
|
||||
onlyResult := imdbResp.Results[0]
|
||||
if onlyResult.MediaType == string(MOVIE) || onlyResult.MediaType == string(SHOW) {
|
||||
// Will only be one result
|
||||
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
|
||||
return successfulImport(db, userId, onlyResult.ID, ContentType(onlyResult.MediaType), ar)
|
||||
} else if onlyResult.MediaType == string(SHOW_EPISODE) {
|
||||
// Handle episodes differently.
|
||||
// Clients must import tv episodes last so that the actual show can be imported first
|
||||
// will fail if watched entry isn't imported first or already exists (we won't make it here).
|
||||
w, e := getWatchedItemByTmdbId(db, userId, uint(onlyResult.ShowId), "tv")
|
||||
if e != nil {
|
||||
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: onlyResult.SeasonNumber,
|
||||
EpisodeNumber: onlyResult.EpisodeNumber,
|
||||
Status: ar.Status,
|
||||
Rating: int8(ar.Rating),
|
||||
addActivityDate: *ar.RatingCustomDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
} else {
|
||||
w.WatchedEpisodes = ws.WatchedEpisodes
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
} else {
|
||||
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
|
||||
return ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
} else {
|
||||
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
|
||||
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
|
||||
}
|
||||
}
|
||||
// tmdbId not passed.. search for the content by name.
|
||||
sr, err := searchContent(ar.Name, 1)
|
||||
if err != nil {
|
||||
slog.Error("import: content search failed", "error", err)
|
||||
return ImportResponse{}, errors.New("Content search failed")
|
||||
@@ -227,5 +275,39 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
w.WatchedEpisodes = ws.WatchedEpisodes
|
||||
}
|
||||
}
|
||||
// Import tags, if any
|
||||
if len(ar.Tags) > 0 {
|
||||
// Create tags if they dont exist
|
||||
slog.Debug("successfulImport: Importing tags")
|
||||
for _, v := range ar.Tags {
|
||||
// Check if tag exists
|
||||
var t Tag
|
||||
t, err := getTagByNameAndColor(db, userId, v.Name, v.Color, v.BgColor)
|
||||
if err != nil && err.Error() != "tag does not exist" {
|
||||
slog.Error("successfulImport: Failed to check for an existing tag", "name", v.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
if t.ID == 0 {
|
||||
tag, err := addTag(db, userId, TagAddRequest{
|
||||
Name: v.Name,
|
||||
Color: v.Color,
|
||||
BgColor: v.BgColor,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add a tag.", "name", v.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
t = tag
|
||||
}
|
||||
|
||||
// Associate the watched entry with the tag
|
||||
err = addWatchedToTag(db, userId, t.ID, w.ID)
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
|
||||
continue
|
||||
}
|
||||
w.Tags = append(w.Tags, t)
|
||||
}
|
||||
}
|
||||
return ImportResponse{Type: IMPORT_SUCCESS, WatchedEntry: w}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
// Trakt.tv importer.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TraktImportRequest struct {
|
||||
// Username of public trakt user to import from.
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type TraktUser struct {
|
||||
Username string `json:"username"`
|
||||
Private bool `json:"private"`
|
||||
IDs struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktHistory struct {
|
||||
ID int64 `json:"id"`
|
||||
WatchedAt time.Time `json:"watched_at"`
|
||||
Action string `json:"action"`
|
||||
Type string `json:"type"`
|
||||
Show TraktListShow `json:"show,omitempty"`
|
||||
Episode TraktListEpisode `json:"episode,omitempty"`
|
||||
Movie TraktListMovie `json:"movie,omitempty"`
|
||||
}
|
||||
|
||||
type TraktWatchlist []struct {
|
||||
Rank int `json:"rank"`
|
||||
ID int `json:"id"`
|
||||
ListedAt time.Time `json:"listed_at"`
|
||||
Notes string `json:"notes"`
|
||||
Type string `json:"type"`
|
||||
Show TraktListShow `json:"show,omitempty"`
|
||||
Episode TraktListEpisode `json:"episode,omitempty"`
|
||||
Movie TraktListMovie `json:"movie,omitempty"`
|
||||
}
|
||||
|
||||
type TraktRatings []struct {
|
||||
Rating int `json:"rating"`
|
||||
Type string `json:"type"`
|
||||
Show TraktListShow `json:"show,omitempty"`
|
||||
Episode TraktListEpisode `json:"episode,omitempty"`
|
||||
Movie TraktListMovie `json:"movie,omitempty"`
|
||||
}
|
||||
|
||||
type TraktListShow struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Ids struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
Tmdb int `json:"tmdb"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktListEpisode struct {
|
||||
Season int `json:"season"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Ids struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
Tmdb int `json:"tmdb"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktListMovie struct {
|
||||
Title string `json:"title"`
|
||||
Year int `json:"year"`
|
||||
Ids struct {
|
||||
Trakt int `json:"trakt"`
|
||||
Slug string `json:"slug"`
|
||||
Tmdb int `json:"tmdb"`
|
||||
} `json:"ids"`
|
||||
}
|
||||
|
||||
type TraktImportResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// TODO we could support trakt list imports when we support a similar feature (tags will function as custom lists when done #199)
|
||||
func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername string) {
|
||||
// Get trakt user. We want to get their profile `slug` for use in
|
||||
// next requests and we can check their profile isn't private while here.
|
||||
var traktUser TraktUser
|
||||
_, err := traktAPIRequest("users/"+traktUsername, map[string]string{}, &traktUser)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users profile", "error", err, "trakt_user", traktUser)
|
||||
addJobError(jobId, userId, "failed to request trakt profile from api")
|
||||
updateJobStatus(jobId, userId, JOB_CANCELLED)
|
||||
return
|
||||
}
|
||||
if traktUser.Private {
|
||||
slog.Error("startTraktImport: Users profile is private. Cannot continue with import.")
|
||||
addJobError(jobId, userId, "trakt profile is private")
|
||||
updateJobStatus(jobId, userId, JOB_CANCELLED)
|
||||
return
|
||||
}
|
||||
userSlug := traktUser.IDs.Slug
|
||||
// Everything will be added to this map for importing at the end.
|
||||
toImport := map[string]ImportRequest{}
|
||||
// Process all history for this user (in chunks of 1000).
|
||||
var history []TraktHistory
|
||||
slog.Debug("startTraktImport: Getting first history page")
|
||||
historyHeaders, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000"}, &history)
|
||||
if err != nil {
|
||||
// FATAL if we can't get the users history, we probably shouldn't continue (to ratings/watchlist below).
|
||||
slog.Error("startTraktImport: Failed to get users history", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your history")
|
||||
return
|
||||
} else {
|
||||
pageCount := historyHeaders.Get("x-pagination-page-count")
|
||||
slog.Debug("startTraktImport: Got first history page", "page_count", pageCount)
|
||||
if pageCount == "" {
|
||||
slog.Error("startTraktImport: Failed to get history page count!", "page_count", pageCount)
|
||||
addJobError(jobId, userId, "Failed to get history page count")
|
||||
return
|
||||
}
|
||||
pageCountNum, err := strconv.Atoi(pageCount)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to parse history page count into an int!", "error", err)
|
||||
addJobError(jobId, userId, "Failed to parse history page count: "+pageCount)
|
||||
return
|
||||
}
|
||||
rProc := func(v TraktHistory) {
|
||||
var collectingText string
|
||||
if v.Type == "episode" {
|
||||
collectingText = fmt.Sprintf("%s S%dE%d", v.Show.Title, v.Episode.Season, v.Episode.Number)
|
||||
} else if v.Type == "show" {
|
||||
collectingText = v.Show.Title
|
||||
} else if v.Type == "movie" {
|
||||
collectingText = v.Movie.Title
|
||||
}
|
||||
if collectingText != "" {
|
||||
updateJobCurrentTask(jobId, userId, "collecting "+collectingText)
|
||||
}
|
||||
err = processTraktHistoryItem(v, toImport)
|
||||
if err != nil {
|
||||
addJobError(jobId, userId, err.Error())
|
||||
}
|
||||
}
|
||||
// Process first page of history (next pages processed below)
|
||||
for _, v := range history {
|
||||
rProc(v)
|
||||
}
|
||||
for i := range pageCountNum {
|
||||
slog.Debug("startTraktImport: Getting history page", "page_num", i)
|
||||
_, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000", "page": strconv.Itoa(i)}, &history)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get a history page", "page_num", i, "error", err)
|
||||
addJobError(jobId, userId, "Failed to get history page: "+strconv.Itoa(i))
|
||||
} else {
|
||||
for _, v := range history {
|
||||
rProc(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
slog.Info("startTraktImport: Finished processing all history")
|
||||
history = nil // clear whatever is lingering in the history slice
|
||||
}
|
||||
// Get watchlist for PLANNED items
|
||||
slog.Info("startTraktImport: Getting whole watchlist")
|
||||
var watchlist TraktWatchlist
|
||||
_, err = traktAPIRequest("users/"+userSlug+"/watchlist", map[string]string{}, &watchlist)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users watchlist! Cannot import planned content.", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your watchlist (planned items cannot be imported)")
|
||||
} else {
|
||||
slog.Debug("startTraktImport: Successfully got whole watchlist")
|
||||
for _, v := range watchlist {
|
||||
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
|
||||
var (
|
||||
title string
|
||||
contentType ContentType
|
||||
tmdbId int
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
contentType = SHOW
|
||||
if v.Type == "episode" {
|
||||
title = v.Episode.Title
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "setting status for "+title)
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
if mv, ok := toImport[mapKey]; ok {
|
||||
// If item already exists in toImport, set its status to planned.
|
||||
if v.Type == "episode" {
|
||||
// For episode entries, we have to find the WatchedEpisode to set its status to planned.
|
||||
weFound := false
|
||||
for i, we := range mv.WatchedEpisodes {
|
||||
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
|
||||
we.Status = PLANNED
|
||||
mv.WatchedEpisodes[i] = we
|
||||
weFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !weFound {
|
||||
mv.WatchedEpisodes = append(mv.WatchedEpisodes, WatchedEpisode{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: PLANNED,
|
||||
GormModel: GormModel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
} else {
|
||||
mv.Status = PLANNED
|
||||
if v.Notes != "" {
|
||||
// episodes dont support notes in watcharr
|
||||
mv.Thoughts = v.Notes
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
}
|
||||
} else {
|
||||
// If the item does not exist in toImport, create it and set it to planned.
|
||||
ti := ImportRequest{
|
||||
Type: contentType,
|
||||
TmdbID: tmdbId,
|
||||
Status: PLANNED,
|
||||
}
|
||||
if v.Type == "episode" {
|
||||
ti.WatchedEpisodes = []WatchedEpisode{{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: PLANNED,
|
||||
GormModel: GormModel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
}}
|
||||
} else {
|
||||
// episodes dont support notes in watcharr
|
||||
ti.Thoughts = v.Notes
|
||||
}
|
||||
toImport[mapKey] = ti
|
||||
}
|
||||
}
|
||||
}
|
||||
// Process ratings
|
||||
slog.Info("startTraktImport: Getting all ratings")
|
||||
var ratings TraktRatings
|
||||
_, err = traktAPIRequest("users/"+userSlug+"/ratings", map[string]string{}, &ratings)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users ratings!", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your ratings (content ratings cannot be imported)")
|
||||
} else {
|
||||
slog.Debug("startTraktImport: Successfully got all ratings")
|
||||
for _, v := range ratings {
|
||||
slog.Debug("startTraktImport: Processing rating item", "item", v)
|
||||
var (
|
||||
title string
|
||||
contentType ContentType
|
||||
tmdbId int
|
||||
traktSlug string
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
traktSlug = v.Show.Ids.Slug
|
||||
contentType = SHOW
|
||||
if v.Type == "episode" {
|
||||
title = v.Episode.Title
|
||||
traktSlug = v.Episode.Ids.Slug
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
traktSlug = v.Movie.Ids.Slug
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
if mv, ok := toImport[mapKey]; ok {
|
||||
if v.Type == "episode" {
|
||||
// For episode entries, we have to find the WatchedEpisode to set its rating.
|
||||
epFound := false
|
||||
for i, we := range mv.WatchedEpisodes {
|
||||
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
|
||||
we.Rating = int8(v.Rating)
|
||||
mv.WatchedEpisodes[i] = we
|
||||
epFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
if !epFound {
|
||||
addJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
|
||||
}
|
||||
} else {
|
||||
mv.Rating = float64(v.Rating)
|
||||
toImport[mapKey] = mv
|
||||
}
|
||||
} else {
|
||||
// Item should be in toImport by now (from history or watchlist) if it has a rating, otherwise we won't import it
|
||||
addJobError(jobId, userId, fmt.Sprintf("cannot import rating of %d for %s. The main content does not exist in your history or watchlist. type: %s traktSlug: %s", v.Rating, title, v.Type, traktSlug))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loop over `toImport` and finally import everything.
|
||||
for _, v := range toImport {
|
||||
_, err := importContent(db, userId, v)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to do import on content!", "error", err, "import_obj", v)
|
||||
addJobError(jobId, userId, fmt.Sprintf("Failed to import %s as %s. tmdbId: %d", v.Type, v.Status, v.TmdbID))
|
||||
}
|
||||
}
|
||||
// We are donezo
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest) error {
|
||||
var (
|
||||
title string
|
||||
traktId int
|
||||
tmdbId int
|
||||
contentType ContentType
|
||||
watchedEpisode WatchedEpisode
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
traktId = v.Show.Ids.Trakt
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
contentType = SHOW
|
||||
if v.Type == "episode" {
|
||||
traktId = v.Episode.Ids.Trakt
|
||||
watchedEpisode = WatchedEpisode{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: FINISHED,
|
||||
// Rating: ,
|
||||
GormModel: GormModel{
|
||||
CreatedAt: v.WatchedAt,
|
||||
},
|
||||
}
|
||||
slog.Debug("processTraktHistoryItem: Processing an episode.", "showTitle", title, "season", v.Episode.Season, "episode", v.Episode.Number)
|
||||
} else {
|
||||
slog.Debug("processTraktHistoryItem: Processing a show.", "contentTitle", title, "contentTmdbId", tmdbId)
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
traktId = v.Movie.Ids.Trakt
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
|
||||
}
|
||||
if tmdbId == 0 {
|
||||
slog.Debug("processTraktHistoryItem: Item had no tmdbId. Cannot process.")
|
||||
return errors.New("Failed to process history: " + title + " type:" + v.Type + " trakt id:" + strconv.Itoa(traktId) + " tmdb id:" + strconv.Itoa(tmdbId) + " error:" + "item had no tmdb id")
|
||||
}
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
if e, ok := toImport[mapKey]; ok {
|
||||
e.WatchedEpisodes = append(toImport[mapKey].WatchedEpisodes, watchedEpisode)
|
||||
toImport[mapKey] = e
|
||||
} else {
|
||||
toImport[mapKey] = ImportRequest{
|
||||
Type: contentType,
|
||||
TmdbID: tmdbId,
|
||||
Status: FINISHED,
|
||||
DatesWatched: []time.Time{v.WatchedAt},
|
||||
WatchedEpisodes: []WatchedEpisode{watchedEpisode},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// `tmdbId` is for the movie or show (not for episodes).
|
||||
func makeTraktMapKey(ct ContentType, tmdbId int) string {
|
||||
return string(ct) + strconv.Itoa(tmdbId)
|
||||
}
|
||||
|
||||
func traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Header, error) {
|
||||
base, err := url.Parse("https://api.trakt.tv")
|
||||
if err != nil {
|
||||
return map[string][]string{}, errors.New("failed to parse api uri")
|
||||
}
|
||||
base.Path += ep
|
||||
if len(p) > 0 {
|
||||
params := url.Values{}
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
base.RawQuery = params.Encode()
|
||||
}
|
||||
slog.Debug("traktAPIRequest", "request_url", base.String())
|
||||
req, err := http.NewRequest("GET", base.String(), nil)
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
req.Header.Add("trakt-api-key", "c481cb044dcd58d83f3fde113741d1e28d19c1bef1bcbfcb9acedee222f3a673")
|
||||
req.Header.Add("trakt-api-version", "2")
|
||||
req.Header.Add("Content-type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
if !(res.StatusCode >= 200 && res.StatusCode <= 299) {
|
||||
slog.Error("traktAPIRequest: non 2xx status code:", "status_code", res.StatusCode)
|
||||
return map[string][]string{}, errors.New("non success status code")
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return map[string][]string{}, err
|
||||
}
|
||||
return res.Header, nil
|
||||
}
|
||||
|
||||
func traktImportWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
traktUsername string,
|
||||
) (TraktImportResponse, error) {
|
||||
jobId, err := addUniqueJob("trakt_import", userId)
|
||||
if err != nil {
|
||||
slog.Error("traktSyncWatched: Failed to create a job", "error", err)
|
||||
return TraktImportResponse{}, err
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startTraktImport(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
traktUsername,
|
||||
)
|
||||
|
||||
return TraktImportResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -38,10 +38,12 @@ var activeJobs = make(map[string]*Job)
|
||||
|
||||
// Add a job to our activeJobs map.
|
||||
// Returns id of job on success, or error if failed to add.
|
||||
// Only return safe errors for display to users, log serious errors.
|
||||
func addJob(name string, userId uint) (string, error) {
|
||||
idk, err := generateString(8)
|
||||
if err != nil {
|
||||
return "", err
|
||||
slog.Error("addJob: Failed to generate a job id!", "error", err)
|
||||
return "", errors.New("failed to generate a job id, please try again")
|
||||
}
|
||||
_, ok := activeJobs[idk]
|
||||
if ok {
|
||||
@@ -56,6 +58,22 @@ func addJob(name string, userId uint) (string, error) {
|
||||
return idk, nil
|
||||
}
|
||||
|
||||
// Add a job, but only if one with the same `name` isn't already running.
|
||||
// Only return safe errors for display to users.
|
||||
func addUniqueJob(name string, userId uint) (string, error) {
|
||||
found := false
|
||||
for _, v := range activeJobs {
|
||||
if v.UserId == userId && v.Name == name && (v.Status == JOB_CREATED || v.Status == JOB_RUNNING) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if found {
|
||||
return "", errors.New("a job of this type is already running, please wait for the existing job to finish")
|
||||
}
|
||||
return addJob(name, userId)
|
||||
}
|
||||
|
||||
func rmJob(id string, userId uint) {
|
||||
slog.Debug("rmJob: Removing a job.", "id", id)
|
||||
v, ok := activeJobs[id]
|
||||
@@ -91,11 +109,11 @@ func updateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
}
|
||||
j.Status = status
|
||||
// If job is set to done, remove it after 1 minute.
|
||||
if status == JOB_DONE {
|
||||
slog.Debug("updateJobStatus: Job set to done. Will be removed after 1m.", "id", id)
|
||||
if status == JOB_DONE || status == JOB_CANCELLED {
|
||||
slog.Debug("updateJobStatus: Job set to done or cancelled. Will be removed after 30m.", "id", id, "status", status)
|
||||
go func() {
|
||||
time.Sleep(1 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 1m.. removing job now.", "id", id)
|
||||
time.Sleep(30 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 30m.. removing job now.", "id", id)
|
||||
rmJob(id, userId)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -91,8 +91,8 @@ type PlexLibraryItemsResponse struct {
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Metadata []struct {
|
||||
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
GUID string `json:"guid"` // Plex guid
|
||||
@@ -193,8 +193,8 @@ type PlexLibraryItemSeasonsResponse struct {
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Directory []struct {
|
||||
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
|
||||
Directory []struct {
|
||||
LeafCount int `json:"leafCount"`
|
||||
Thumb string `json:"thumb"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
@@ -256,8 +256,8 @@ type PlexLibraryItemEpisodesResponse struct {
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
ViewMode int `json:"viewMode"`
|
||||
Metadata []struct {
|
||||
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
|
||||
@@ -79,7 +79,7 @@ func startPlexSync(
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
Rating: int8(movie.UserRating),
|
||||
Rating: float64(movie.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
@@ -146,7 +146,7 @@ func startPlexSync(
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
Rating: int8(show.UserRating),
|
||||
Rating: float64(show.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -79,13 +80,112 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
exp := time.Hour * 24
|
||||
|
||||
// Search for content
|
||||
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// println(c.Param("query"))
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
content.GET("/search/multi", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := searchContent(c.Param("query"))
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchContent(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for movies
|
||||
content.GET("/search/movie", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchMovies(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for shows
|
||||
content.GET("/search/tv", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchTv(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for people
|
||||
content.GET("/search/person", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchPeople(query, pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for content with external id
|
||||
content.GET("/search/ext/:id/:source", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := searchByExternalId(c.Param("id"), c.Param("source"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -150,7 +250,8 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// Supports `watchedId` query parameter for saving the requested season as `LastViewedSeason`.
|
||||
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -160,8 +261,30 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// If a `watchedId` is passed, we should update it with this season
|
||||
// number, so the LastViewedSeason field is up to date (this seemed
|
||||
// better than making a new request for just saving this).
|
||||
// We will attach a `watcharr-lastviewedseason-saved` header if
|
||||
// this part succeeds so the client can decide on showing an error.
|
||||
if watchedIdQ := c.Query("watchedId"); watchedIdQ != "" {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
watchedId, err := strconv.ParseUint(watchedIdQ, 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("get season details route: Processing watchedId param failed", "error", err.Error(), "id", watchedIdQ)
|
||||
} else {
|
||||
if seasonNum, err := strconv.ParseInt(c.Param("num"), 10, 64); err == nil {
|
||||
if err = updateWatchedLastViewedSeason(b.db, userId, uint(watchedId), int(seasonNum)); err == nil {
|
||||
c.Header("watcharr-lastviewedseason-saved", "1")
|
||||
}
|
||||
} else {
|
||||
slog.Error("get season details route: Parsing season number as int failed", "error", err.Error(), "season_num", c.Param("num"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Debug("get season details route: No watchedId parameter found.. not doing anything.")
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
})
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
@@ -271,12 +394,32 @@ func (b *BaseRouter) addGameRoutes() {
|
||||
}
|
||||
|
||||
// Search for games
|
||||
gamer.GET("/search/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
gamer.GET("/search", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
decodedQuery, err := url.QueryUnescape(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter invalid"})
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(decodedQuery)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}))
|
||||
|
||||
// Search for game by id (for search page, same minimal details as /search returned)
|
||||
gamer.GET("/search/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
games, err := igdb.Search(c.Param("query"))
|
||||
games, err := igdb.SearchById(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -472,6 +615,50 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST(":id/tag/:tagId", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = addWatchedToTag(b.db, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
watched.DELETE(":id/tag/:tagId", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = rmWatchedFromTag(b.db, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
@@ -594,6 +781,28 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Proxy Login
|
||||
auth.POST("/proxy", func(c *gin.Context) {
|
||||
var user User
|
||||
if !trustedHeaderAuthIsEnabled() {
|
||||
slog.Error("ProxyLogin: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
user.Username = c.GetHeader(Config.HEADER_AUTH.HeaderName)
|
||||
if user.Username == "" {
|
||||
slog.Error("ProxyLogin: Authentication header is missing.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "authentication header missing"})
|
||||
return
|
||||
}
|
||||
response, err := loginTrustedHeaderAuth(&user, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user UserRegisterRequest
|
||||
@@ -611,23 +820,45 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
|
||||
// Get available auth providers
|
||||
auth.GET("/available", func(c *gin.Context) {
|
||||
availableAuthProviders := []string{}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "plex")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
resp := &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: []string{},
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
})
|
||||
UseEmby: Config.USE_EMBY,
|
||||
}
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "plex")
|
||||
}
|
||||
if trustedHeaderAuthIsEnabled() {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "header")
|
||||
resp.HeaderAuthAutoLogin = Config.HEADER_AUTH.AutoLogin
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// IMPORTANT: Routes below here must be authenticated.
|
||||
auth.Use(AuthRequired(nil))
|
||||
{
|
||||
// Request details for logout process for proxy users.
|
||||
// Any proxy user can request this for logout.
|
||||
auth.GET("/proxy_logout_details", func(c *gin.Context) {
|
||||
if !trustedHeaderAuthIsEnabled() {
|
||||
slog.Error("GetProxy: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if userType != PROXY_USER {
|
||||
slog.Error("GetProxy: Non proxy user attempted to fetch proxy logout details.")
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: "you are not a proxy user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, getTrustedHeaderAuthLogoutDetails())
|
||||
})
|
||||
|
||||
// Request admin token
|
||||
auth.GET("/admin_token", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
@@ -783,9 +1014,14 @@ func (b *BaseRouter) addUserRoutes() {
|
||||
})
|
||||
|
||||
// Search users
|
||||
u.GET("/search/:query", func(c *gin.Context) {
|
||||
u.GET("/search", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := userSearch(b.db, userId, c.Param("query"))
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
response, err := userSearch(b.db, userId, query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -920,6 +1156,22 @@ func (b *BaseRouter) addImportRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
imprt.POST("/trakt", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar TraktImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := traktImportWatched(b.db, userId, ar.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addServerRoutes() {
|
||||
@@ -927,18 +1179,53 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
server.GET("/config", func(c *gin.Context) {
|
||||
// s should be provided when asking for the value of just one setting.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
val, err := Config.Get(s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
return
|
||||
}
|
||||
// Return new ServerConfig with only the fields we want to show in settings ui
|
||||
c.JSON(http.StatusOK, Config.GetSafe())
|
||||
})
|
||||
|
||||
// Update config
|
||||
server.POST("/config", func(c *gin.Context) {
|
||||
// If query param `s` provided, handle specific setting.
|
||||
// In this case, request body should be new setting value.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
switch s {
|
||||
case "HEADER_AUTH":
|
||||
var ur TrustedHeaderAuthSetting
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err = setTrustedHeaderAuthSetting(ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: "unsupported setting"})
|
||||
return
|
||||
}
|
||||
// No `s` param.. handle normally with `updateConfig` func.
|
||||
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()})
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
@@ -1393,3 +1680,119 @@ func (b *BaseRouter) addJobRoutes() {
|
||||
c.JSON(http.StatusOK, *response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addTaskRoutes() {
|
||||
task := b.rg.Group("/task").Use(AuthRequired(b.db), AdminRequired())
|
||||
|
||||
task.GET("/", func(c *gin.Context) {
|
||||
response := getAllTasks()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
task.PUT(":name", func(c *gin.Context) {
|
||||
if c.Param("name") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "no task name provided"})
|
||||
return
|
||||
}
|
||||
var rr TaskRescheduleRequest
|
||||
err := c.ShouldBindJSON(&rr)
|
||||
if err == nil {
|
||||
err := rescheduleTask(c.Param("name"), rr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addTagRoutes() {
|
||||
tag := b.rg.Group("/tag").Use(AuthRequired(nil))
|
||||
|
||||
// Get list of all our tags.
|
||||
tag.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
tags, err := getTags(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tags)
|
||||
})
|
||||
|
||||
// // Get all items within one of our tags.
|
||||
// tag.GET(":id", func(c *gin.Context) {
|
||||
// id, err := strconv.Atoi(c.Param("id"))
|
||||
// if err != nil {
|
||||
// slog.Error("getTag route failed to convert id param to int", "error", err)
|
||||
// c.Status(http.StatusBadRequest)
|
||||
// return
|
||||
// }
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
// tags, err := getTag(b.db, userId, uint(id))
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
// return
|
||||
// }
|
||||
// c.JSON(http.StatusOK, tags)
|
||||
// })
|
||||
|
||||
// Create a tag.
|
||||
tag.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var tr TagAddRequest
|
||||
err := c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
response, err := addTag(b.db, userId, tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Update a tag.
|
||||
tag.PUT(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag update rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
var tr TagAddRequest
|
||||
err = c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
err := updateTag(b.db, userId, uint(id), tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
tag.DELETE(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag delete rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
err = deleteTag(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// I think tags will be private for the user.
|
||||
// If the user wants to make a public list, they should make a custom view.
|
||||
|
||||
type Tag struct {
|
||||
GormModel
|
||||
// ID of user that own this tag.
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// Name of the tag.
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
// Hex of text color.
|
||||
Color string `json:"color"`
|
||||
// Hex of background color.
|
||||
BgColor string `json:"bgColor"`
|
||||
// All watched items.
|
||||
Watched []Watched `json:"watched,omitempty" gorm:"many2many:watched_tags;"`
|
||||
}
|
||||
|
||||
type TagAddRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
BgColor string `json:"bgColor"`
|
||||
}
|
||||
|
||||
func getTags(db *gorm.DB, userId uint) ([]Tag, error) {
|
||||
tags := new([]Tag)
|
||||
res := db.Model(&Tag{}).Where("user_id = ?", userId).Find(&tags)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTags: Failed getting tags from database", "error", res.Error.Error())
|
||||
return []Tag{}, errors.New("failed getting tags")
|
||||
}
|
||||
return *tags, nil
|
||||
}
|
||||
|
||||
// func getTag(db *gorm.DB, userId uint, tagId uint) (Tag, error) {
|
||||
// tag := new(Tag)
|
||||
// res := db.Model(&Tag{}).Where("id = ? AND user_id = ?", tagId, userId).Preload("Watched").Find(&tag)
|
||||
// if res.Error != nil {
|
||||
// slog.Error("getTag: Failed getting tag from database", "error", res.Error.Error())
|
||||
// return Tag{}, errors.New("failed getting tag")
|
||||
// }
|
||||
// if tag.ID == 0 {
|
||||
// slog.Error("getTag: Tag does not exist for this user.", "user_id", userId)
|
||||
// return Tag{}, errors.New("tag does not exist")
|
||||
// }
|
||||
// return *tag, nil
|
||||
// }
|
||||
|
||||
// This method should only be used when we don't have the tagId
|
||||
// (eg: when we are importing data) because this is not technically
|
||||
// reliable, since users can have multiple tags with the same name/colors
|
||||
// (realistically they probably won't, but...).
|
||||
func getTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (Tag, error) {
|
||||
tag := new(Tag)
|
||||
res := db.Model(&Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed getting tag")
|
||||
}
|
||||
if tag.ID == 0 {
|
||||
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
|
||||
return Tag{}, errors.New("tag does not exist")
|
||||
}
|
||||
return *tag, nil
|
||||
}
|
||||
|
||||
// Let user create a tag.
|
||||
func addTag(db *gorm.DB, userId uint, tr TagAddRequest) (Tag, error) {
|
||||
if tr.Name == "" {
|
||||
return Tag{}, errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{UserID: userId, Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Create(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding tag to database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed adding new tag to database")
|
||||
}
|
||||
slog.Debug("Adding tag", "added_tag", tag)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Let user update one of their tags (replaces).
|
||||
func updateTag(db *gorm.DB, userId uint, tagId uint, tr TagAddRequest) error {
|
||||
if tr.Name == "" {
|
||||
return errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Where("id = ? AND user_id = ?", tagId, userId).Updates(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating tag in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating tag in database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("updateTag: Zero rows affected.. tag likely does not exist", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
slog.Debug("updateTag:", "updated_tag", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Let user delete their own tag.
|
||||
func deleteTag(db *gorm.DB, userId uint, tagId uint) error {
|
||||
if tagId == 0 {
|
||||
return errors.New("no tag id provided")
|
||||
}
|
||||
slog.Debug("deleteTag:", "tag_id", tagId, "user_id", userId)
|
||||
// Select("Watched") so relations in watched_tags table are removed too.
|
||||
// ID is passed in the .Delete param so the .Select call can do it's job (relies on the primary key).
|
||||
res := db.Unscoped().Where("id = ? AND user_id = ?", tagId, userId).Select("Watched").Delete(&Tag{GormModel: GormModel{ID: tagId}})
|
||||
if res.Error != nil {
|
||||
slog.Error("deleteTag: Error deleting tag from database", "error", res.Error.Error(), "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("failed deleting tag from database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("deleteTag: Zero rows affected.. tag must not exist for user", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add watched content to a tag (user must own the tag and watched entry).
|
||||
func addWatchedToTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("addWatchedToTag: Adding", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Save relation (unique restraint will fail if it already exists)
|
||||
w.Tags = append(w.Tags, t)
|
||||
resp := db.Save(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to tag watched item", "error", resp.Error)
|
||||
return errors.New("failed to tag watched item")
|
||||
}
|
||||
slog.Debug("addWatchedToTag: watched content successfully linked to tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove watched content from a tag (user must own the tag and watched entry).
|
||||
func rmWatchedFromTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("rmWatchedFromTag: Removing", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Remove relation
|
||||
err := db.Model(&w).Association("Tags").Delete(&t)
|
||||
if err != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to untag watched item", "error", err)
|
||||
return errors.New("failed to untag watched item")
|
||||
}
|
||||
slog.Debug("rmWatchedFromTag: watched content successfully removed from tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
@@ -1,36 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TaskRescheduleRequest struct {
|
||||
// Number of seconds inbetween each run of this task.
|
||||
Seconds int `json:"seconds" binding:"required"`
|
||||
}
|
||||
|
||||
type AllTasksResponse struct {
|
||||
// The tasks name.
|
||||
Name string `json:"name"`
|
||||
// When this task will next run.
|
||||
NextRun time.Time `json:"nextRun"`
|
||||
// Current schedule for this task (seconds).
|
||||
Seconds int `json:"seconds"`
|
||||
}
|
||||
|
||||
type TaskFunc struct {
|
||||
// Task function.
|
||||
f func()
|
||||
// Default duration (schedule) for task.
|
||||
dd time.Duration
|
||||
}
|
||||
|
||||
var taskScheduler gocron.Scheduler
|
||||
|
||||
// All task functions are stored here so when updating (rescheduling)
|
||||
// a job, we can give it this function again.
|
||||
// Doesn't seem to be a way to only update the schedule of a job,
|
||||
// the .Update func wants the whole definition again.
|
||||
//
|
||||
// All funcs simply call a cleaning/routine method where the rest of the
|
||||
// related code lives so it's kept tidy.
|
||||
var taskFuncs map[string]TaskFunc
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(db *gorm.DB) {
|
||||
go setupMinutelyTasks(db)
|
||||
go setupDailyTasks(db)
|
||||
}
|
||||
|
||||
func setupMinutelyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 1 * time.Minute
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Runs funcs that are in the place where we are cleaning.
|
||||
// Bit cleaner and we can keep the related code close to its home.
|
||||
cleanupTokens(db)
|
||||
refreshArrQueues()
|
||||
ts, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
slog.Error("SetupTasks: Failed to create new scheduler!", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
taskScheduler = ts
|
||||
|
||||
func setupDailyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 24 * time.Hour
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
cleanupImages(db)
|
||||
// Define all task funcs.
|
||||
taskFuncs = map[string]TaskFunc{
|
||||
"Cleanup Tokens": {
|
||||
f: func() {
|
||||
cleanupTokens(db)
|
||||
},
|
||||
dd: 60 * time.Second,
|
||||
},
|
||||
"Refresh Arr Queues": {
|
||||
f: func() {
|
||||
refreshArrQueues()
|
||||
},
|
||||
dd: 60 * time.Second,
|
||||
},
|
||||
"Cleanup Images": {
|
||||
f: func() {
|
||||
cleanupImages(db)
|
||||
},
|
||||
dd: 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
// Add all jobs to scheduler.
|
||||
for k, v := range taskFuncs {
|
||||
err = addTaskToScheduler(k, v.dd)
|
||||
if err != nil {
|
||||
slog.Error("SetupTasks: Failed to add new job", "job", k, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
taskScheduler.Start()
|
||||
slog.Info("SetupTasks: Jobs created and scheduler started.")
|
||||
}
|
||||
|
||||
// Gets schedule from config, or `defaultDur` if not manually configured.
|
||||
func getTaskSeconds(name string, defaultDur time.Duration) time.Duration {
|
||||
s := defaultDur
|
||||
if Config.TASK_SCHEDULE[name] != 0 {
|
||||
s = time.Duration(Config.TASK_SCHEDULE[name]) * time.Second
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Add new job to scheduler.
|
||||
func addTaskToScheduler(name string, defaultDur time.Duration) error {
|
||||
s := getTaskSeconds(name, defaultDur)
|
||||
_, err := taskScheduler.NewJob(
|
||||
gocron.DurationJob(s),
|
||||
gocron.NewTask(taskFuncs[name].f),
|
||||
gocron.WithName(name),
|
||||
)
|
||||
slog.Debug("addTaskToScheduler: Job added.", "job_name", name, "duration_used", s, "duration_default", defaultDur)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get all tasks in a consumable format.
|
||||
func getAllTasks() []AllTasksResponse {
|
||||
jobs := []AllTasksResponse{}
|
||||
for _, j := range taskScheduler.Jobs() {
|
||||
j2a := AllTasksResponse{
|
||||
Name: j.Name(),
|
||||
}
|
||||
nextRun, err := j.NextRun()
|
||||
if err != nil {
|
||||
slog.Error("getAllTasks: Failed to get next run time for a job.", "job_name", j2a.Name)
|
||||
} else {
|
||||
j2a.NextRun = nextRun
|
||||
}
|
||||
j2a.Seconds = int(getTaskSeconds(j2a.Name, taskFuncs[j2a.Name].dd).Seconds())
|
||||
jobs = append(jobs, j2a)
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Get task (job) from scheduler by name.
|
||||
func getTask(name string) *gocron.Job {
|
||||
var job *gocron.Job
|
||||
for _, j := range taskScheduler.Jobs() {
|
||||
if j.Name() == name {
|
||||
job = &j
|
||||
break
|
||||
}
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// Reschedule a task by name.
|
||||
func rescheduleTask(name string, req TaskRescheduleRequest) error {
|
||||
if req.Seconds == 0 {
|
||||
return errors.New("request has no seconds")
|
||||
}
|
||||
j := getTask(name)
|
||||
if j == nil {
|
||||
return errors.New("no task found")
|
||||
}
|
||||
// Update config
|
||||
if Config.TASK_SCHEDULE == nil {
|
||||
Config.TASK_SCHEDULE = map[string]int{}
|
||||
}
|
||||
Config.TASK_SCHEDULE[name] = req.Seconds
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("rescheduleTask: Failed to write updated config to file!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
// Update job in scheduler
|
||||
_, err := taskScheduler.Update(
|
||||
(*j).ID(),
|
||||
gocron.DurationJob(
|
||||
time.Duration(req.Seconds)*time.Second,
|
||||
),
|
||||
gocron.NewTask(
|
||||
taskFuncs[name].f,
|
||||
),
|
||||
gocron.WithName(name),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rescheduleTask: Failed to update job!", "error", err)
|
||||
return errors.New("failed to update job")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []TMDBSearchMultiResults `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
Page int `json:"page"`
|
||||
Results []R `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResults struct {
|
||||
@@ -38,6 +38,108 @@ type TMDBSearchMultiResults struct {
|
||||
OriginalName string `json:"original_name,omitempty"`
|
||||
FirstAirDate string `json:"first_air_date,omitempty"`
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
// Below are for tv episode results
|
||||
AirDate string `json:"air_date,omitempty"`
|
||||
EpisodeNumber int `json:"episode_number,omitempty"`
|
||||
EpisodeType string `json:"episode_type,omitempty"`
|
||||
ProductionCode string `json:"production_code,omitempty"`
|
||||
Runtime int `json:"runtime,omitempty"`
|
||||
SeasonNumber int `json:"season_number,omitempty"`
|
||||
ShowId int `json:"show_id,omitempty"`
|
||||
StillPath string `json:"still_path,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResults]
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResult]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResult]
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
KnownFor []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"known_for"`
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchPeopleResult]
|
||||
}
|
||||
|
||||
type TMDBFindByExternalIdResponse struct {
|
||||
// These are all a TMDBSearchMultiResults so our search func can easily
|
||||
// combine all of them into one []TMDBSearchMultiResults for response
|
||||
// to client (seems not easy to convert to TMDBSearchMultiResults for
|
||||
// concatenation after unmarshalling to correct type).
|
||||
MovieResults []TMDBSearchMultiResults `json:"movie_results"`
|
||||
PersonResults []TMDBSearchMultiResults `json:"person_results"`
|
||||
TvResults []TMDBSearchMultiResults `json:"tv_results"`
|
||||
TvSeasonResults []TMDBSearchMultiResults `json:"tv_season_results"`
|
||||
TvEpisodeResults []TMDBSearchMultiResults `json:"tv_episode_results"`
|
||||
}
|
||||
|
||||
type TMDBContentDetails struct {
|
||||
|
||||
@@ -58,15 +58,25 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
if ur.IncludePreviouslyWatched != nil {
|
||||
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
|
||||
}
|
||||
if ur.AutomateShowStatuses != nil {
|
||||
user.AutomateShowStatuses = ur.AutomateShowStatuses
|
||||
}
|
||||
if ur.Country != nil {
|
||||
user.Country = ur.Country
|
||||
}
|
||||
if ur.RatingSystem != nil {
|
||||
user.RatingSystem = ur.RatingSystem
|
||||
}
|
||||
if ur.RatingStep != nil {
|
||||
user.RatingStep = ur.RatingStep
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
AutomateShowStatuses: user.AutomateShowStatuses,
|
||||
Country: user.Country,
|
||||
}, nil
|
||||
}
|
||||
@@ -84,7 +94,10 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
AutomateShowStatuses: user.AutomateShowStatuses,
|
||||
Country: user.Country,
|
||||
RatingSystem: user.RatingSystem,
|
||||
RatingStep: user.RatingStep,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ type ManagedUser struct {
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Permissions *int `json:"permissions"`
|
||||
Permissions *int `json:"permissions"`
|
||||
Type *UserType `json:"type"`
|
||||
}
|
||||
|
||||
func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
@@ -34,7 +35,7 @@ func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
// Update a user. For management views, for admin to update another user.
|
||||
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
// Error now if no userId or any UpdateUserRequest property was provided.
|
||||
if userId == 0 || (ur.Permissions == nil) {
|
||||
if userId == 0 || (ur.Permissions == nil && ur.Type == nil) {
|
||||
slog.Error("manageUser: invalid arguments", "user_id", userId)
|
||||
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
|
||||
}
|
||||
@@ -49,6 +50,16 @@ func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
toUpdate["permissions"] = *ur.Permissions
|
||||
}
|
||||
}
|
||||
if ur.Type != nil {
|
||||
t := *ur.Type
|
||||
if t == WATCHARR_USER || t == PROXY_USER {
|
||||
// Currently only swapping between watcharr/proxy user is supported.
|
||||
slog.Debug("manageUser: User type is being updated.", "new_type", t)
|
||||
toUpdate["type"] = t
|
||||
} else {
|
||||
slog.Warn("manageUser: User type will not be updated. Only watcharr/proxy types are supported for swapping.", "tried_type", t)
|
||||
}
|
||||
}
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
|
||||
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update user in database")
|
||||
|
||||
@@ -82,6 +82,7 @@ func main() {
|
||||
&Image{},
|
||||
&Game{},
|
||||
&ArrRequest{},
|
||||
&Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
@@ -94,10 +95,23 @@ func main() {
|
||||
gin.DefaultWriter = multiw
|
||||
gine := gin.Default()
|
||||
gine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Accept-Encoding",
|
||||
"X-CSRF-Token",
|
||||
"Authorization",
|
||||
"accept",
|
||||
"origin",
|
||||
"Cache-Control",
|
||||
"X-Requested-With",
|
||||
},
|
||||
ExposeHeaders: []string{
|
||||
"Content-Length",
|
||||
"watcharr-lastviewedseason-saved",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
@@ -143,6 +157,8 @@ func main() {
|
||||
br.addRadarrRoutes()
|
||||
br.addArrRequestRoutes()
|
||||
br.addJobRoutes()
|
||||
br.addTaskRoutes()
|
||||
br.addTagRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
|
||||
@@ -21,15 +21,19 @@ const (
|
||||
FINISHED WatchedStatus = "FINISHED"
|
||||
WATCHING WatchedStatus = "WATCHING"
|
||||
PLANNED WatchedStatus = "PLANNED"
|
||||
HOLD WatchedStatus = "ONHOLD"
|
||||
HOLD WatchedStatus = "HOLD"
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
// float so we can support decimal ratings.
|
||||
// Ratings should still always be saved as out of 10.0,
|
||||
// so they can be viewed with any ratings setting in the client.
|
||||
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Pinned bool `json:"pinned" gorm:"default:false;not null"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
@@ -38,11 +42,15 @@ type Watched struct {
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:watched_tags;"`
|
||||
// The last season that was viewed by the user for this watched entry.
|
||||
// Only applies to tv shows of course.
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
@@ -52,10 +60,11 @@ type WatchedAddRequest struct {
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts"`
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
|
||||
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
@@ -68,13 +77,49 @@ type WatchedRemoveResponse struct {
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Preload("WatchedSeasons").Preload("WatchedEpisodes").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched
|
||||
}
|
||||
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
func getWatchedItemById(db *gorm.DB, userId uint, id uint) (Watched, error) {
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Where("user_id = ? AND id = ?", userId, id).Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemById: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by content (tmdb) id (must be for `userId`).
|
||||
func getWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType ContentType) (Watched, error) {
|
||||
slog.Debug("getWatchedItemByTmdbId: Running.", "userId", userId, "tmdbId", tmdbId)
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Joins("Content").
|
||||
Where("user_id = ? AND Content.tmdb_id = ? AND Content.type = ?", userId, tmdbId, contentType).
|
||||
Take(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemByTmdbId: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
slog.Debug("getWatchedItemByTmdbId: Done.", "userId", userId, "tmdbId", tmdbId, "watched_item", watched)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
@@ -194,6 +239,9 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
if ar.RemoveThoughts {
|
||||
upwat.Thoughts = ""
|
||||
}
|
||||
if ar.Pinned != nil {
|
||||
upwat.Pinned = *ar.Pinned
|
||||
}
|
||||
res = db.Save(upwat)
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
@@ -214,6 +262,24 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func updateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error {
|
||||
slog.Debug("UpdateWatchedLastViewedSeason", "user_id", userId, "id", id, "season_num", seasonNum)
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where("id = ? AND user_id = ?", id, userId).
|
||||
Update("last_viewed_season", seasonNum)
|
||||
if res.Error != nil {
|
||||
slog.Error("updateWatchedLastViewedSeason: Failed when updating.", "error", res.Error)
|
||||
return errors.New("failed to update db")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// likely the watched entry does not exist or is not owned by this `userId`.
|
||||
slog.Error("updateWatchedLastViewedSeason: Watched entry does not exist.")
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
@@ -232,45 +298,67 @@ func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, er
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func download(url string, outf string) (err error) {
|
||||
slog.Debug("Attempting to download file", "url", url, "outf", outf)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Download file over http (used for downloading poster images)
|
||||
// url - The remote file url.
|
||||
// outf - Where should we store the downloaded file.
|
||||
// force - Should we overwrite an existing file? If false, existing files will be skipped.
|
||||
func download(url string, outf string, force bool) (err error) {
|
||||
slog.Debug("download: Attempting to download file", "url", url, "outf", outf, "force", force)
|
||||
// If not forced, skip call if file already exists to save unnecessary requests.
|
||||
if !force {
|
||||
if _, err := os.Stat(outf); !errors.Is(err, os.ErrNotExist) {
|
||||
slog.Debug("download: Skipping file, it already exists locally.", "outf", outf, "error", err)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
slog.Debug("download: Continuing to download file, it does not already exist.", "outf", outf, "error", err)
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to make request.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Error("download: Request failed. Non OK response.", "outf", outf, "status", resp.Status, "error", err)
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("download: Failed to create out file, trying to recover by ensuring directories exist.", "outf", outf)
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create dir(s) in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create out file again in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("download: recovered by creating dir(s).", "outf", outf)
|
||||
} else {
|
||||
slog.Error("download: Failed to create out file. No known recovery path possible.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to write file to our file.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("download: Successfully downloaded file", "outf", outf)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type WatchedEpisodeAddRequest struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
}
|
||||
@@ -39,6 +39,8 @@ type WatchedEpisodeAddRequest struct {
|
||||
type WatchedEpisodeAddResponse struct {
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
AddedActivity Activity `json:"addedActivity"`
|
||||
// Response from hook
|
||||
EpisodeStatusChangedHookResponse EpisodeStatusChangedHookResponse `json:"episodeStatusChangedHookResponse,omitempty"`
|
||||
}
|
||||
|
||||
// Add/edit a watched episode.
|
||||
@@ -115,10 +117,15 @@ func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (
|
||||
}
|
||||
addedActivity, _ = addActivity(db, userId, act)
|
||||
}
|
||||
return WatchedEpisodeAddResponse{
|
||||
episodeAddResp := WatchedEpisodeAddResponse{
|
||||
WatchedEpisodes: w.WatchedEpisodes,
|
||||
AddedActivity: addedActivity,
|
||||
}, nil
|
||||
}
|
||||
if ar.Status != "" {
|
||||
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
|
||||
episodeAddResp.EpisodeStatusChangedHookResponse = hookEpisodeStatusChanged(db, userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
|
||||
}
|
||||
return episodeAddResp, nil
|
||||
}
|
||||
|
||||
// Remove a watched episode
|
||||
@@ -147,3 +154,11 @@ func rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (Activity, error) {
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
|
||||
func getNumberOfWatchedEpisodesInSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int, acceptableStatus []WatchedStatus) (int64, error) {
|
||||
var count int64
|
||||
if res := db.Model(&WatchedEpisode{}).Where("user_id = ? AND watched_id = ? AND season_number = ? AND status IN ?", userId, watchedId, seasonNumber, acceptableStatus).Count(&count); res.Error != nil {
|
||||
return 0, res.Error
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EpisodeStatusChangedHookResponse struct {
|
||||
// The watched shows status if we modified it.
|
||||
NewShowStatus WatchedStatus `json:"newShowStatus,omitempty"`
|
||||
// The full watched season (if created or modified).
|
||||
WatchedSeason *WatchedSeason `json:"watchedSeason,omitempty"`
|
||||
// All activies we have added.
|
||||
AddedActivities []Activity `json:"addedActivities,omitempty"`
|
||||
// All errors (fatal and non-fatal) that were encountered.
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// Called after an episode watched status has been set.
|
||||
func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNum int, episodeNum int, newEpisodeStatus WatchedStatus) EpisodeStatusChangedHookResponse {
|
||||
userSettings, err := userGetSettings(db, userId)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get user settings! Hook will continue.", "error", err)
|
||||
} else {
|
||||
if !*userSettings.AutomateShowStatuses {
|
||||
slog.Debug("hookEpisodeStatusChanged: User has AutomateShowStatuses disabled. Skipping hook.", "user_id", userId)
|
||||
return EpisodeStatusChangedHookResponse{}
|
||||
}
|
||||
}
|
||||
|
||||
hookResponse := EpisodeStatusChangedHookResponse{}
|
||||
|
||||
addHookActivity := func(aType ActivityType, data string) {
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
|
||||
}
|
||||
|
||||
// 2. If the season (this episode is in) has no status or is planned, set season to watching.
|
||||
watchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Cannot continue, failed to get watchedSeason!", "error", err)
|
||||
return EpisodeStatusChangedHookResponse{Errors: []string{("failed to query db for watched season")}}
|
||||
}
|
||||
// If season not found, create it.
|
||||
if watchedSeason == nil {
|
||||
slog.Debug("hookEpisodeStatusChanged: Watched season does not exist. Creating now.")
|
||||
seasonStatus := newEpisodeStatus
|
||||
if newEpisodeStatus == FINISHED || newEpisodeStatus == DROPPED {
|
||||
seasonStatus = WATCHING
|
||||
}
|
||||
resp, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
addActivity: SEASON_ADDED_AUTO,
|
||||
addActivityData: map[string]interface{}{"reason": fmt.Sprintf("Episode %d was set to %s while the season had no status.", episodeNum, newEpisodeStatus)},
|
||||
WatchedID: watchedId,
|
||||
SeasonNumber: seasonNum,
|
||||
Status: seasonStatus,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to add watched season!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to add watched season")
|
||||
} else {
|
||||
// addWatchedSeason returns all watched seasons, get the one just added. (may be best to retrofit addWatchedSeason later to return id of season/row created)
|
||||
justAddedWatchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
|
||||
if err != nil {
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get newly added watched season for response")
|
||||
} else {
|
||||
watchedSeason = justAddedWatchedSeason
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
}
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, resp.AddedActivity)
|
||||
}
|
||||
} else if watchedSeason.Status == "" || watchedSeason.Status == PLANNED ||
|
||||
((newEpisodeStatus == FINISHED || newEpisodeStatus == WATCHING) && (watchedSeason.Status == HOLD || watchedSeason.Status == DROPPED)) {
|
||||
reasonStr := fmt.Sprintf("Episode %d was set to %s while the season had ", episodeNum, newEpisodeStatus)
|
||||
if watchedSeason.Status == "" {
|
||||
reasonStr += "no status."
|
||||
} else {
|
||||
reasonStr += fmt.Sprintf("a status of %s.", watchedSeason.Status)
|
||||
}
|
||||
watchedSeason.Status = WATCHING
|
||||
if res := db.Save(watchedSeason); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update season status!", "error", res.Error)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status")
|
||||
} else {
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": watchedSeason.Status, "reason": reasonStr})
|
||||
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If the show has no status or is planned, set it to watching.
|
||||
watchedShow, err := getWatchedItemById(db, userId, watchedId)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get watched show, cant continue to update show status.", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get watched item for show")
|
||||
return hookResponse
|
||||
} else {
|
||||
// Show status shouldn't be empty, but watevs, handle it just incase
|
||||
if watchedShow.Status == "" || watchedShow.Status == PLANNED {
|
||||
watchedShow.Status = WATCHING
|
||||
if res := db.Save(watchedShow); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update show status!", "error", res.Error)
|
||||
} else {
|
||||
hookResponse.NewShowStatus = watchedShow.Status
|
||||
json, _ := json.Marshal(map[string]interface{}{"status": watchedShow.Status, "reason": fmt.Sprintf("S%dE%d was set to %s.", seasonNum, episodeNum, newEpisodeStatus)})
|
||||
addHookActivity(STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If all episodes are FINISHED or DROPPED, set the season to FINISHED
|
||||
// BUG If a seasons status is removed and the last episode of the season is marked finished,
|
||||
// this will add activity for the season being marked finished, right after it is set
|
||||
// to Watching just above. I think this might never happen to anyone so um ye.
|
||||
tmdbIdStr := strconv.Itoa(watchedShow.Content.TmdbID)
|
||||
seasonNumStr := strconv.Itoa(seasonNum)
|
||||
seasonDetails, err := seasonDetails(tmdbIdStr, seasonNumStr)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get season details!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get season details for show")
|
||||
return hookResponse
|
||||
}
|
||||
allEpisodesCount := len(seasonDetails.Episodes)
|
||||
finishedEpisodesCount, err := getNumberOfWatchedEpisodesInSeason(db, userId, watchedId, seasonNum, []WatchedStatus{FINISHED, DROPPED})
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get number of watched episodes in this season!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get number of watched episodes in this season")
|
||||
return hookResponse
|
||||
}
|
||||
slog.Debug("hookEpisodeStatusChanged: Got episode counts.", "allEpisodesCount", allEpisodesCount, "finishedEpisodesCount", finishedEpisodesCount)
|
||||
if finishedEpisodesCount >= int64(allEpisodesCount) {
|
||||
slog.Debug("hookEpisodeStatusChanged: All episodes have been completed (finished or dropped). Marking season finished.")
|
||||
newStatus := FINISHED
|
||||
if watchedSeason != nil && watchedSeason.Status == newStatus {
|
||||
slog.Debug("hookEpisodeStatusChanged: WatchedSeason status is same as newStatus so not updating.")
|
||||
return hookResponse
|
||||
}
|
||||
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNum, userId).Update("status", newStatus); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update season status to finished:", "error", res.Error.Error())
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status to finished")
|
||||
return hookResponse
|
||||
} else {
|
||||
if watchedSeason != nil {
|
||||
watchedSeason.Status = newStatus
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
} else {
|
||||
slog.Error("hookEpisodeStatusChanged: watchedSeason was nil HOW DID THIS HAPPEN? Anyways the client won't be able to update its state with the new season status until it is refreshed.")
|
||||
}
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": newStatus, "reason": fmt.Sprintf("The season was deemed completed when episode %d was set to %s.", episodeNum, newEpisodeStatus)})
|
||||
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
|
||||
return hookResponse
|
||||
}
|
||||
@@ -25,9 +25,12 @@ type WatchedSeasonAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
// Data to add to activity if the season is created.
|
||||
// Combined with data we already add.
|
||||
addActivityData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddResponse struct {
|
||||
@@ -98,7 +101,15 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
actData := map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating}
|
||||
if len(ar.addActivityData) > 0 {
|
||||
for k, v := range ar.addActivityData {
|
||||
if _, ok := ar.addActivityData[k]; ok {
|
||||
actData[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
json, _ := json.Marshal(actData)
|
||||
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
|
||||
if ar.addActivity != "" {
|
||||
act.Type = ar.addActivity
|
||||
@@ -139,3 +150,15 @@ func rmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (Activity, error)
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
|
||||
func getWatchedSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int) (*WatchedSeason, error) {
|
||||
var ws *WatchedSeason
|
||||
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNumber, userId).Take(&ws); res.Error != nil {
|
||||
slog.Error("getWatchedSeason: Failed to get:", "error", res.Error.Error())
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &WatchedSeason{}, errors.New("failed to get watched season")
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
@@ -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,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Watcharr</title>
|
||||
%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" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>Watcharr</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { ClientInit } from "@sveltejs/kit";
|
||||
|
||||
export const init: ClientInit = async () => {
|
||||
console.info(
|
||||
`%cWATCHARR v${__WATCHARR_VERSION__}`,
|
||||
"background: white;color: black;font-size: large;padding: 3px 5px;",
|
||||
);
|
||||
};
|
||||
@@ -1,285 +1,326 @@
|
||||
<script lang="ts">
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
import type { Activity } from "@/types";
|
||||
import {
|
||||
getOrdinalSuffix,
|
||||
months,
|
||||
seasonAndEpToReadable,
|
||||
} from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import tooltip from "./actions/tooltip";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
export let wListId: number;
|
||||
interface Props {
|
||||
activity: Activity[] | undefined;
|
||||
wListId: number;
|
||||
}
|
||||
|
||||
let clickedActivity: Activity;
|
||||
let groupedActivities: { [index: string]: any };
|
||||
let isActivityEditorVisible: boolean;
|
||||
let { activity, wListId }: Props = $props();
|
||||
|
||||
$: {
|
||||
groupedActivities = getGroupedActivity(activity);
|
||||
}
|
||||
let clickedActivity: Activity | undefined = $state();
|
||||
let groupedActivities: { [index: string]: any } = $derived(
|
||||
getGroupedActivity(activity),
|
||||
);
|
||||
|
||||
function getMsg(a: Activity) {
|
||||
switch (a?.type) {
|
||||
case "ADDED_WATCHED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} Stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "Added to Watched List";
|
||||
case "REMOVED_WATCHED":
|
||||
return "Removed from Watched List";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `Rating Changed to ${a.data}`;
|
||||
}
|
||||
return "Rating Changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `Status Changed to ${toFullTitleCase(a.data)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "Thoughts Changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "Thoughts Removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "Imported";
|
||||
case "IMPORTED_WATCHED_JF":
|
||||
case "IMPORTED_WATCHED_PLEX":
|
||||
return "Synced";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
if (data.rating) {
|
||||
return `Rating Changed to ${data.rating}`;
|
||||
} else {
|
||||
return "Added to Watchlist with No Rating";
|
||||
}
|
||||
}
|
||||
return "Imported Rating";
|
||||
case "IMPORTED_ADDED_WATCHED":
|
||||
case "IMPORTED_ADDED_WATCHED_JF":
|
||||
case "IMPORTED_ADDED_WATCHED_PLEX":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Added";
|
||||
case "SEASON_ADDED_JF":
|
||||
case "SEASON_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Synced";
|
||||
case "SEASON_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Rating to ${data.rating}`;
|
||||
}
|
||||
return "Season Rating Changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Status Changed";
|
||||
case "SEASON_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Status Removed`;
|
||||
}
|
||||
return "Season Removed";
|
||||
case "EPISODE_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Synced";
|
||||
case "EPISODE_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
|
||||
}
|
||||
return "Episode Rating Changed";
|
||||
case "EPISODE_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Episode Status Changed";
|
||||
case "EPISODE_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
|
||||
}
|
||||
return "Episode Removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
function getMsg(a: Activity) {
|
||||
switch (a?.type) {
|
||||
case "ADDED_WATCHED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Added to Watched List${data?.status ? ` as ${toFullTitleCase(data.status)}` : ""}${
|
||||
data?.rating ? ` with ${data.rating} Stars` : ""
|
||||
}`;
|
||||
}
|
||||
return "Added to Watched List";
|
||||
case "REMOVED_WATCHED":
|
||||
return "Removed from Watched List";
|
||||
case "RATING_CHANGED":
|
||||
if (a.data) {
|
||||
return `Rating Changed to ${a.data}`;
|
||||
}
|
||||
return "Rating Changed";
|
||||
case "STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
return `Status Changed to ${toFullTitleCase(a.data)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "Thoughts Changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
return "Thoughts Removed";
|
||||
case "IMPORTED_WATCHED":
|
||||
return "Imported";
|
||||
case "IMPORTED_WATCHED_JF":
|
||||
case "IMPORTED_WATCHED_PLEX":
|
||||
return "Synced";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
if (data.rating) {
|
||||
return `Rating Changed to ${data.rating}`;
|
||||
} else {
|
||||
return "Added to Watchlist with No Rating";
|
||||
}
|
||||
}
|
||||
return "Imported Rating";
|
||||
case "IMPORTED_ADDED_WATCHED":
|
||||
case "IMPORTED_ADDED_WATCHED_JF":
|
||||
case "IMPORTED_ADDED_WATCHED_PLEX":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
case "SEASON_ADDED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Added";
|
||||
case "SEASON_ADDED_JF":
|
||||
case "SEASON_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Synced";
|
||||
case "SEASON_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Rating to ${data.rating}`;
|
||||
}
|
||||
return "Season Rating Changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
case "SEASON_STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Status Changed";
|
||||
case "SEASON_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Status Removed`;
|
||||
}
|
||||
return "Season Removed";
|
||||
case "EPISODE_ADDED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Added ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
}
|
||||
return "Episode Synced";
|
||||
case "EPISODE_RATING_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Rating Changed to ${data.rating}`;
|
||||
}
|
||||
return "Episode Rating Changed";
|
||||
case "EPISODE_STATUS_CHANGED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Episode Status Changed";
|
||||
case "EPISODE_REMOVED":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Removed`;
|
||||
}
|
||||
return "Episode Removed";
|
||||
default:
|
||||
return a.type;
|
||||
}
|
||||
}
|
||||
|
||||
function toFullTitleCase(text: string | undefined) {
|
||||
if (text) {
|
||||
return text
|
||||
.split(" ")
|
||||
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
function toFullTitleCase(text: string | undefined) {
|
||||
if (text) {
|
||||
return text
|
||||
.split(" ")
|
||||
.map((l) => l[0].toUpperCase() + l.substring(1).toLowerCase())
|
||||
.join(" ");
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
function toDayTime(d: Date) {
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} at ${d.toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get what will be the visible date the activity was 'created'.
|
||||
* @returns customDate if defined or createdAt if not.
|
||||
*/
|
||||
function getCreatedAtVis(a: Activity) {
|
||||
return Date.parse(a.customDate ?? a.createdAt);
|
||||
}
|
||||
/**
|
||||
* Get what will be the visible date the activity was 'created'.
|
||||
* @returns customDate if defined or createdAt if not.
|
||||
*/
|
||||
function getCreatedAtVis(a: Activity) {
|
||||
return Date.parse(a.customDate ?? a.createdAt);
|
||||
}
|
||||
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort((a, b) => getCreatedAtVis(b) - getCreatedAtVis(a));
|
||||
let grouped: { [index: string]: any } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
const date = new Date(getCreatedAtVis(activity));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
} else {
|
||||
grouped[key] = [activity];
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
function getGroupedActivity(activities?: Activity[]) {
|
||||
activities = activities?.filter((a) => a.type);
|
||||
const a = activities?.sort(
|
||||
(a, b) => getCreatedAtVis(b) - getCreatedAtVis(a),
|
||||
);
|
||||
let grouped: { [index: string]: any } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
const date = new Date(getCreatedAtVis(activity));
|
||||
const key = `${months[date.getMonth()]} ${date.getFullYear()}`;
|
||||
if (grouped[key]) {
|
||||
grouped[key].push(activity);
|
||||
} else {
|
||||
grouped[key] = [activity];
|
||||
}
|
||||
}
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
|
||||
function openEditor(a: Activity) {
|
||||
clickedActivity = a;
|
||||
isActivityEditorVisible = true;
|
||||
return;
|
||||
}
|
||||
function openEditor(a: Activity) {
|
||||
clickedActivity = a;
|
||||
return;
|
||||
}
|
||||
|
||||
function getActivityDataParsed(a: Activity) {
|
||||
try {
|
||||
if (a.data) {
|
||||
return JSON.parse(a.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("getActivityDataParsed: Failed!", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isActivityEditorVisible}
|
||||
<ActivityEditor
|
||||
watchedId={wListId}
|
||||
activity={clickedActivity}
|
||||
activityMessage={getMsg(clickedActivity)}
|
||||
onClose={() => (isActivityEditorVisible = false)}
|
||||
/>
|
||||
{#if clickedActivity}
|
||||
<ActivityEditor
|
||||
watchedId={wListId}
|
||||
activity={clickedActivity}
|
||||
activityMessage={getMsg(clickedActivity)}
|
||||
onClose={() => (clickedActivity = undefined)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="activity">
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
<h3>{k}</h3>
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
<h3>{k}</h3>
|
||||
|
||||
{#each groupedActivities[k] as a}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<button class="unset" on:click={() => openEditor(a)}>
|
||||
<span title={d.toDateString()}>{toDayTime(d)}</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span>You Have No Activity!</span>
|
||||
{/if}
|
||||
{#each groupedActivities[k] as a}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<button class="plain" onclick={() => openEditor(a)}>
|
||||
<span title={d.toDateString()}>{toDayTime(d)}</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</button>
|
||||
{#if a.type?.endsWith("_AUTO")}
|
||||
{@const data = getActivityDataParsed(a)}
|
||||
<i
|
||||
use:tooltip={{
|
||||
text:
|
||||
data && data.reason
|
||||
? `Automated because ${data.reason}`
|
||||
: "Completed by an automation.",
|
||||
pos: "bot",
|
||||
}}
|
||||
style="width: 20px; height: 20px;"
|
||||
>
|
||||
<Icon i="sparkles" wh={20} />
|
||||
</i>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<span>You Have No Activity!</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.activity {
|
||||
width: 100%;
|
||||
.activity {
|
||||
width: 100%;
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: calc(30px + 8px);
|
||||
list-style: none;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
ul {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
margin-left: calc(30px + 8px);
|
||||
list-style: none;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
h3 {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
padding-bottom: 1px;
|
||||
background-color: $bg-color;
|
||||
}
|
||||
|
||||
li {
|
||||
button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
span {
|
||||
width: max-content;
|
||||
margin-left: 0px;
|
||||
button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
min-width: max-content;
|
||||
}
|
||||
span {
|
||||
width: max-content;
|
||||
margin-left: 0px;
|
||||
|
||||
&:last-child {
|
||||
background-color: $accent-color;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:first-child {
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: calc(30px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
&:last-of-type {
|
||||
background-color: $accent-color;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
span {
|
||||
margin-left: calc(30px);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,131 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { updateActivity, removeActivity } from "@/lib/util/api";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Activity } from "@/types";
|
||||
import { notify } from "./util/notify";
|
||||
import { updateActivity, removeActivity } from "@/lib/util/api";
|
||||
import Modal from "./Modal.svelte";
|
||||
import type { Activity } from "@/types";
|
||||
import { notify } from "./util/notify";
|
||||
|
||||
export let watchedId: number;
|
||||
export let activity: Activity;
|
||||
export let activityMessage: string;
|
||||
export let onClose: () => void;
|
||||
interface Props {
|
||||
watchedId: number;
|
||||
activity: Activity;
|
||||
activityMessage: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let isDateTimeChanged: boolean;
|
||||
let isDateTimeValid = true;
|
||||
let currentDateObject = new Date(Date.parse(activity.customDate ?? activity.createdAt));
|
||||
let currentDateString = dateToInputDateString(currentDateObject);
|
||||
let currentTimeString = dateToInputTimeString(currentDateObject);
|
||||
let selectedDateString = currentDateString;
|
||||
let selectedTimeString = currentTimeString;
|
||||
let { watchedId, activity, activityMessage, onClose }: Props = $props();
|
||||
|
||||
$: {
|
||||
isDateTimeChanged =
|
||||
currentDateString != selectedDateString || currentTimeString != selectedTimeString;
|
||||
}
|
||||
let isDateTimeChanged: boolean = $derived(
|
||||
currentDateString != selectedDateString ||
|
||||
currentTimeString != selectedTimeString,
|
||||
);
|
||||
let isDateTimeValid = $state(true);
|
||||
let currentDateObject = new Date(
|
||||
Date.parse(activity.customDate ?? activity.createdAt),
|
||||
);
|
||||
let currentDateString = dateToInputDateString(currentDateObject);
|
||||
let currentTimeString = dateToInputTimeString(currentDateObject);
|
||||
let selectedDateString = $state(currentDateString);
|
||||
let selectedTimeString = $state(currentTimeString);
|
||||
|
||||
function dateToInputDateString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
function dateToInputDateString(date: Date) {
|
||||
const year = date.getFullYear();
|
||||
const month = (date.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = date.getDate().toString().padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function dateToInputTimeString(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
function dateToInputTimeString(date: Date) {
|
||||
const hours = date.getHours().toString().padStart(2, "0");
|
||||
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||
return `${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function validateNewDate() {
|
||||
try {
|
||||
const epochMillis = Date.parse(`${selectedDateString} ${selectedTimeString}`);
|
||||
const dateObj = new Date(epochMillis);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
isDateTimeValid = false;
|
||||
return;
|
||||
}
|
||||
isDateTimeValid = true;
|
||||
return dateObj;
|
||||
} catch (err) {
|
||||
console.error("ActivityEditor: validateNewDate failed!", err);
|
||||
isDateTimeValid = false;
|
||||
}
|
||||
}
|
||||
function validateNewDate() {
|
||||
try {
|
||||
const epochMillis = Date.parse(
|
||||
`${selectedDateString} ${selectedTimeString}`,
|
||||
);
|
||||
const dateObj = new Date(epochMillis);
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
isDateTimeValid = false;
|
||||
return;
|
||||
}
|
||||
isDateTimeValid = true;
|
||||
return dateObj;
|
||||
} catch (err) {
|
||||
console.error("ActivityEditor: validateNewDate failed!", err);
|
||||
isDateTimeValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
function update() {
|
||||
const dateObj = validateNewDate();
|
||||
if (dateObj && isDateTimeValid && isDateTimeChanged) {
|
||||
updateActivity(watchedId, activity.id, dateObj);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
notify({ text: "Unable to try updating!", type: "error" });
|
||||
console.error(
|
||||
"ActivityEditor: Can't try updating, data missing/invalid:",
|
||||
dateObj,
|
||||
isDateTimeValid,
|
||||
isDateTimeChanged
|
||||
);
|
||||
}
|
||||
function update() {
|
||||
const dateObj = validateNewDate();
|
||||
if (dateObj && isDateTimeValid && isDateTimeChanged) {
|
||||
updateActivity(watchedId, activity.id, dateObj);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
notify({ text: "Unable to try updating!", type: "error" });
|
||||
console.error(
|
||||
"ActivityEditor: Can't try updating, data missing/invalid:",
|
||||
dateObj,
|
||||
isDateTimeValid,
|
||||
isDateTimeChanged,
|
||||
);
|
||||
}
|
||||
|
||||
function remove() {
|
||||
removeActivity(watchedId, activity.id);
|
||||
onClose();
|
||||
}
|
||||
function remove() {
|
||||
removeActivity(watchedId, activity.id);
|
||||
onClose();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal title="Edit Activity" desc={activityMessage} maxWidth="400px" {onClose}>
|
||||
<div class="centered">
|
||||
<h3>Date</h3>
|
||||
<input
|
||||
id="activity-date"
|
||||
type="date"
|
||||
bind:value={selectedDateString}
|
||||
on:change={validateNewDate}
|
||||
class:invalid={!isDateTimeValid}
|
||||
/>
|
||||
<h3>Time</h3>
|
||||
<input
|
||||
id="activity-time"
|
||||
type="time"
|
||||
bind:value={selectedTimeString}
|
||||
on:change={validateNewDate}
|
||||
/>
|
||||
<div class="centered">
|
||||
<h3>Date</h3>
|
||||
<input
|
||||
id="activity-date"
|
||||
type="date"
|
||||
bind:value={selectedDateString}
|
||||
onchange={validateNewDate}
|
||||
class:invalid={!isDateTimeValid}
|
||||
/>
|
||||
<h3>Time</h3>
|
||||
<input
|
||||
id="activity-time"
|
||||
type="time"
|
||||
bind:value={selectedTimeString}
|
||||
onchange={validateNewDate}
|
||||
/>
|
||||
|
||||
<div class="button-row">
|
||||
<button class="danger" on:click={remove}>Delete</button>
|
||||
<div>
|
||||
<button on:click={update} disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button class="danger" onclick={remove}>Delete</button>
|
||||
<div>
|
||||
<button
|
||||
onclick={update}
|
||||
disabled={!(isDateTimeChanged && isDateTimeValid)}>Update</button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.centered {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
.centered {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
height: 100%;
|
||||
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
button {
|
||||
margin-top: auto;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
margin-top: auto;
|
||||
width: max-content;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,87 +1,96 @@
|
||||
<script lang="ts">
|
||||
export let name: string;
|
||||
export let value: boolean = false;
|
||||
export let toggled: (on: boolean) => void = () => {};
|
||||
export let disabled = false;
|
||||
interface Props {
|
||||
name: string;
|
||||
value?: boolean;
|
||||
toggled?: (on: boolean) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let actualDisabled = false;
|
||||
let {
|
||||
name,
|
||||
value = $bindable(),
|
||||
toggled = () => {},
|
||||
disabled = false,
|
||||
}: Props = $props();
|
||||
|
||||
$: {
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
let actualDisabled = $state(false);
|
||||
|
||||
function checkboxChange(e: Event) {
|
||||
toggled((e.target as HTMLInputElement).checked);
|
||||
}
|
||||
$effect(() => {
|
||||
// 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>
|
||||
<input
|
||||
bind:checked={value}
|
||||
disabled={actualDisabled}
|
||||
type="checkbox"
|
||||
id={name}
|
||||
onchange={checkboxChange}
|
||||
/>
|
||||
<label for={name}></label>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.toggle-pill-color {
|
||||
width: min-content;
|
||||
.toggle-pill-color {
|
||||
width: min-content;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
input {
|
||||
display: none;
|
||||
|
||||
&:checked + label {
|
||||
background: $success;
|
||||
&:checked + label {
|
||||
background: $success;
|
||||
|
||||
&::before {
|
||||
left: 1.6em;
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
left: 1.6em;
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled + label {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
&: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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
&::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,33 @@
|
||||
<!-- Uses Native Color Input -->
|
||||
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
value?: string;
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { value = $bindable("#000000"), style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<button
|
||||
aria-label="Open color picker"
|
||||
class="plain"
|
||||
style={`background-color: ${value};${style}`}
|
||||
>
|
||||
<input type="color" bind:value />
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
outline: 1px solid $accent-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,181 +1,202 @@
|
||||
<script lang="ts">
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let options: string[] | DropDownItem[];
|
||||
// If we are using DropDownItems[] as options.
|
||||
export let isDropDownItem = false;
|
||||
export let active: string | number | undefined = undefined;
|
||||
export let placeholder: string;
|
||||
export let blendIn: boolean = false;
|
||||
export let disabled = false;
|
||||
export let onChange: () => void = () => {};
|
||||
interface Props {
|
||||
options: string[] | DropDownItem[];
|
||||
// If we are using DropDownItems[] as options.
|
||||
isDropDownItem?: boolean;
|
||||
active?: string | number | undefined;
|
||||
placeholder: string;
|
||||
blendIn?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange?: () => void;
|
||||
showActiveElementsInOptions?: boolean;
|
||||
}
|
||||
|
||||
let activeValue: string;
|
||||
let open = false;
|
||||
let ulElement: HTMLUListElement;
|
||||
let mainElement: HTMLDivElement;
|
||||
let {
|
||||
options,
|
||||
isDropDownItem = false,
|
||||
active = $bindable(undefined),
|
||||
placeholder,
|
||||
blendIn = false,
|
||||
disabled = false,
|
||||
onChange = () => {},
|
||||
showActiveElementsInOptions = false,
|
||||
}: Props = $props();
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (!open || disabled) return; // Don't handle if closed or disabled
|
||||
let activeValue: string | undefined = $state();
|
||||
let open = $state(false);
|
||||
let ulElement: HTMLUListElement | undefined = $state();
|
||||
let mainElement: HTMLDivElement | undefined = $state();
|
||||
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (!open || disabled) return; // Don't handle if closed or disabled
|
||||
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter)
|
||||
);
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
|
||||
}
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter),
|
||||
);
|
||||
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f =
|
||||
typeof filteredOptions[0] === "string"
|
||||
? filteredOptions[0]
|
||||
: filteredOptions[0].id;
|
||||
}
|
||||
|
||||
$: {
|
||||
if (typeof active === "string" && !isDropDownItem) {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
}
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
if (btns) {
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mainElement.addEventListener("keypress", handleKeyPress);
|
||||
$effect(() => {
|
||||
if (typeof active === "string" && !isDropDownItem) {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) =>
|
||||
typeof o !== "string" ? o.id === active : false,
|
||||
);
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mainElement.removeEventListener("keypress", handleKeyPress);
|
||||
};
|
||||
});
|
||||
onMount(() => {
|
||||
mainElement?.addEventListener("keypress", handleKeyPress);
|
||||
|
||||
return () => {
|
||||
mainElement?.removeEventListener("keypress", handleKeyPress);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
open ? "is-open" : "",
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : ""
|
||||
].join(" ")}
|
||||
bind:this={mainElement}
|
||||
class={[
|
||||
open ? "is-open" : "",
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : "",
|
||||
].join(" ")}
|
||||
bind:this={mainElement}
|
||||
>
|
||||
<button on:click={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
on:click={() => {
|
||||
active = typeof o == "string" ? o : o.id;
|
||||
open = false;
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
{typeof o == "string" ? o : o.value}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<button onclick={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each showActiveElementsInOptions ? options : options.filter( (o) => (typeof o === "string" ? o !== active : o.id !== active), ) as o}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
active = typeof o == "string" ? o : o.id;
|
||||
open = false;
|
||||
onChange();
|
||||
}}
|
||||
>
|
||||
{typeof o == "string" ? o : o.value}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
position: relative;
|
||||
div {
|
||||
position: relative;
|
||||
|
||||
&.placeholder-shown {
|
||||
& > button {
|
||||
color: #8e8e8e;
|
||||
&.placeholder-shown {
|
||||
& > button {
|
||||
color: #8e8e8e;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(:disabled) {
|
||||
color: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.blend-in {
|
||||
& > button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
&.blend-in {
|
||||
& > button {
|
||||
border-color: transparent;
|
||||
background-color: transparent;
|
||||
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.is-open {
|
||||
ul {
|
||||
display: flex;
|
||||
}
|
||||
&.is-open {
|
||||
ul {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
& > button {
|
||||
border-color: $text-color;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
& > button {
|
||||
border-color: $text-color;
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
gap: 3px;
|
||||
text-transform: capitalize;
|
||||
button {
|
||||
gap: 3px;
|
||||
text-transform: capitalize;
|
||||
|
||||
:global(svg) {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
: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;
|
||||
max-height: 20vh;
|
||||
overflow-y: 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;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
li {
|
||||
width: 100%;
|
||||
|
||||
button {
|
||||
padding: 5px 10px;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
text-transform: capitalize;
|
||||
transition: background-color 100ms ease;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover:not(:disabled),
|
||||
&:focus-visible:not(:disabled) {
|
||||
background-color: $text-color;
|
||||
color: $bg-color;
|
||||
fill: $bg-color;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,121 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import type { Icon as IconT } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import type { Icon as IconT } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
|
||||
export let text: string;
|
||||
export let icon: IconT = "document";
|
||||
export let filesSelected: (f?: FileList | null) => void;
|
||||
export let allowSelectMultipleFiles = false;
|
||||
interface Props {
|
||||
text: string;
|
||||
icon?: IconT;
|
||||
filesSelected: (f?: FileList | null) => void;
|
||||
allowSelectMultipleFiles?: boolean;
|
||||
}
|
||||
|
||||
let fileInput: HTMLInputElement;
|
||||
let dragEnterTarget: EventTarget | null;
|
||||
let isDragOver = false;
|
||||
let {
|
||||
text,
|
||||
icon = "document",
|
||||
filesSelected,
|
||||
allowSelectMultipleFiles = false,
|
||||
}: Props = $props();
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
let fileInput: HTMLInputElement = $state();
|
||||
let dragEnterTarget: EventTarget | null = $state();
|
||||
let isDragOver = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="drop-file-btn">
|
||||
<button
|
||||
on:click={importFile}
|
||||
on:dragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
on:dragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
on:dragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
on:drop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
filesSelected(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : icon} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
Browse For {text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
</button>
|
||||
<input type="file" multiple={allowSelectMultipleFiles} bind:this={fileInput} />
|
||||
<button
|
||||
onclick={importFile}
|
||||
ondragover={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}}
|
||||
ondragenter={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
dragEnterTarget = ev.target;
|
||||
console.log("enter");
|
||||
isDragOver = true;
|
||||
}}
|
||||
ondragleave={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
if (dragEnterTarget === ev.target) {
|
||||
console.log("leave");
|
||||
isDragOver = false;
|
||||
}
|
||||
}}
|
||||
ondrop={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
filesSelected(ev.dataTransfer?.files);
|
||||
}}
|
||||
class={isDragOver ? "dragging-over" : ""}
|
||||
>
|
||||
<Icon i={isDragOver ? "add" : icon} wh="100%" />
|
||||
<div>
|
||||
<h4 class="norm">
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
{text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
multiple={allowSelectMultipleFiles}
|
||||
bind:this={fileInput}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.drop-file-btn {
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
.drop-file-btn {
|
||||
button {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 180px;
|
||||
padding: 20px;
|
||||
background-color: $accent-color;
|
||||
border: unset;
|
||||
border-radius: 10px;
|
||||
user-select: none;
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
:global {
|
||||
#reel path {
|
||||
transition: 180ms ease-in-out;
|
||||
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
&:first-of-type {
|
||||
fill: transparent;
|
||||
}
|
||||
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:last-of-type {
|
||||
fill: $text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
&:hover,
|
||||
&.dragging-over {
|
||||
color: $bg-color;
|
||||
background-color: $accent-color-hover;
|
||||
|
||||
:global(#reel path:last-of-type) {
|
||||
fill: $bg-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
: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;
|
||||
}
|
||||
}
|
||||
input[type="file"] {
|
||||
width: 0px;
|
||||
overflow: hidden;
|
||||
border: unset;
|
||||
background-color: transparent;
|
||||
position: absolute;
|
||||
top: -500px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,48 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
interface Props {
|
||||
pretty: string;
|
||||
error: any;
|
||||
onRetry?: () => void | undefined;
|
||||
}
|
||||
|
||||
let { pretty, error, onRetry = undefined! }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
{#if onRetry}
|
||||
<button onclick={onRetry}>Try Again</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 5px;
|
||||
text-transform: capitalize;
|
||||
font-size: 16px;
|
||||
div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
max-width: 500px;
|
||||
gap: 5px;
|
||||
text-transform: capitalize;
|
||||
font-size: 16px;
|
||||
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||