Compare commits
501 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e6ca542ec | |||
| a60710e384 | |||
| fbf3002fa1 | |||
| fce668ef97 | |||
| 939ff6cdaf | |||
| 6a716f7507 | |||
| 8f80153d22 | |||
| d3d476cc3f | |||
| 2f78fdd0a5 | |||
| 79f1d1b242 | |||
| b946dfd3c9 | |||
| 69c656bc97 | |||
| 10bf5324b2 | |||
| fcdf752c6c | |||
| df425b9587 | |||
| 78eee0e27e | |||
| 0ea1fea88a | |||
| 18c0882650 | |||
| b70ae5d3c7 | |||
| 01e175ee9f | |||
| 409b2c9d41 | |||
| 902a444704 | |||
| 2c0983724a | |||
| bf0a1eaa14 | |||
| 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 | |||
| 3684c79477 | |||
| b749bffa15 | |||
| b800369165 | |||
| 5ac07c0f6e | |||
| 434d3fca3d | |||
| 57c7fb1dec | |||
| f209709200 | |||
| f5b15854e0 | |||
| 6aec9a938a | |||
| 8180780c87 | |||
| a8b369fdb5 | |||
| 0c6a550871 | |||
| b006fafbdf | |||
| 3c0c014b6a | |||
| c086348a44 | |||
| 6bc18e61ed | |||
| fc714ebfe0 | |||
| 5018805ffe | |||
| c6c48950c4 | |||
| 75baa5468c | |||
| c462aca6ed | |||
| c5d817c68c | |||
| 4a7503bbfd | |||
| c08686a255 | |||
| 6cad057e58 | |||
| d62e911cc2 | |||
| 82b0941cd4 | |||
| 3a5ed7cdc0 | |||
| ae32bb4955 | |||
| 42ca418d55 | |||
| a4fab86472 | |||
| 0c5c9eba6e | |||
| a58d940473 | |||
| c415bf4c52 | |||
| cb59a9e6b1 | |||
| bedcfcc07c | |||
| a95365525a | |||
| f54c8feb2f | |||
| 4dee46a7f7 | |||
| a7e564af1e | |||
| f72fa54931 | |||
| 0089ea416e | |||
| a163e4c9be | |||
| b5028f5405 | |||
| 70ed52cec4 | |||
| 63700a5a1e | |||
| 60323ecd61 | |||
| 49b3c8e96a | |||
| 2d39e29db0 | |||
| 8c39ec881e | |||
| 3ea7001986 | |||
| ec4b9c993f | |||
| 248935c4e7 | |||
| cfbc8def98 | |||
| 6453f7c274 | |||
| 7261f2f454 | |||
| 1e6e22942c | |||
| 3b556ef5e7 | |||
| dc05c7aaeb | |||
| ef0d119907 | |||
| a12ac3c4a0 | |||
| 564788a4c8 | |||
| 06ddda1137 | |||
| 48a969b367 | |||
| 5c08980e27 | |||
| 96575c2c6d | |||
| ea3a5d99f0 | |||
| c47b9a07b8 | |||
| c8743fadb6 | |||
| 42f2cde43f | |||
| ac1ac76891 | |||
| 7abed85a56 | |||
| f20e52bbe6 | |||
| bea8294f26 | |||
| ca11301410 | |||
| b1f48309aa | |||
| d989f13621 | |||
| 85cd946619 | |||
| 40420aa14b | |||
| aec1014acf | |||
| fc49e9986f | |||
| c9e23e1eba | |||
| f992841314 | |||
| afb1fa6dfd | |||
| a58351c539 | |||
| d643d349ac | |||
| 4fdb3e9f9d | |||
| c1be94738b | |||
| ea66615f65 | |||
| 5b38f762ed | |||
| 2aa81f036e | |||
| cbf0120647 | |||
| 2865115b1f | |||
| 5cbc817089 |
@@ -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?
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
image: sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-dev-image:
|
||||
@@ -27,19 +27,29 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: sbondco
|
||||
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
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.image }}:dev
|
||||
labels: ${{ env.image }}:${{ github.sha }}
|
||||
tags: |
|
||||
${{ env.image }}:dev
|
||||
ghcr.io/${{ env.image }}:dev
|
||||
labels: |
|
||||
${{ env.image }}:${{ github.sha }}
|
||||
ghcr.io/${{ env.image }}:${{ github.sha }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- v*
|
||||
|
||||
env:
|
||||
image: ghcr.io/sbondco/watcharr
|
||||
image: sbondco/watcharr
|
||||
|
||||
jobs:
|
||||
release-images:
|
||||
@@ -26,25 +26,37 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: sbondco
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.image }}
|
||||
images: |
|
||||
${{ env.image }}
|
||||
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
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: |
|
||||
${{ steps.meta.outputs.tags }}
|
||||
ghcr.io/${{ steps.meta.outputs.tags }}
|
||||
labels: |
|
||||
${{ steps.meta.outputs.labels }}
|
||||
ghcr.io/${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
# Popular action to deploy to GitHub Pages:
|
||||
# Docs: https://github.com/peaceiris/actions-gh-pages#%EF%B8%8F-docusaurus
|
||||
- name: Deploy to GitHub Pages
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
uses: peaceiris/actions-gh-pages@v4
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
|
||||
@@ -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,34 +18,37 @@ 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>
|
||||
|
||||
@@ -53,8 +56,22 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
|
||||
[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.
|
||||
|
||||
# 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).
|
||||
@@ -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")],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"label": "Importing",
|
||||
"position": 3,
|
||||
"link": {
|
||||
"type": "generated-index",
|
||||
"description": "Guides for importing your data from other services."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 8.6 KiB |
@@ -0,0 +1,17 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# MyAnimeList
|
||||
|
||||
:::info Backup?
|
||||
You may consider a backup of your server before starting any import. They are not easily reversible, though we do our best to ensure they are accurate and bug free!
|
||||
:::
|
||||
|
||||
1. Export your list from MyAnimeList at: https://myanimelist.net/panel.php?go=export
|
||||
|
||||

|
||||
|
||||
2. Save the export anywhere and extract it to get your export `.xml` file.
|
||||
|
||||
3. Drag and drop your `.xml` file into Watcharr.
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: Install and setup with Docker Compose.
|
||||
---
|
||||
|
||||
# Docker Compose
|
||||
@@ -9,8 +10,6 @@ sidebar_position: 1
|
||||
Installing Watcharr with a docker compose file is easy. You can copy the example below to get started:
|
||||
|
||||
```yaml title="docker-compose.yml"
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
description: Building from source for developing upon Watcharr.
|
||||
---
|
||||
|
||||
# For Development
|
||||
|
||||
This guide will help you setup Watcharr for development in your local environment.
|
||||
|
||||
## Setup
|
||||
|
||||
This section assumes you will be forking the repo on Github, of course you can alter this section to work with your workflow, but if you intend to create a pull request with your work, it may be difficult if you're not also using Github (I don't know :()).
|
||||
|
||||
1. Fork the repository
|
||||
|
||||
On GitHub you can fork the repository with the 'Fork' button at the top.
|
||||
|
||||

|
||||
|
||||
2. Get the code by cloning your fork
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your username>/Watcharr.git && \
|
||||
cd Watcharr
|
||||
```
|
||||
|
||||
## Setting Up Dependencies
|
||||
|
||||
You only need to do this once after pulling the codebase for the first time, and again every time the dependencies are changed/updated.
|
||||
|
||||
1. Install frontend dependencies (in the project root folder)
|
||||
|
||||
```
|
||||
npm i
|
||||
```
|
||||
|
||||
2. Install server dependencies (in the `server` folder)
|
||||
|
||||
```
|
||||
go get .
|
||||
```
|
||||
|
||||
## Running The Code
|
||||
|
||||
To run the code, you will need to open up two terminals, one for the frontend and the other for the backend.
|
||||
|
||||
1. Run the frontend (first terminal, in the project root folder)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. Run the server (second terminal, in the project root folder)
|
||||
|
||||
```bash
|
||||
npm run server
|
||||
```
|
||||
|
||||
**Note:** If you're using Windows, running the server can be a little more complicated. You can follow this: https://github.com/sbondCo/Watcharr/discussions/430#discussioncomment-8894110 which amounts to these steps (the first 3 steps only need to be done once):
|
||||
|
||||
1. Set CGO_ENABLED environment variable with go
|
||||
|
||||
```
|
||||
go env -w CGO_ENABLED=1
|
||||
```
|
||||
|
||||
2. Install `gcc` (if you don't already have it installed) from https://jmeubank.github.io/tdm-gcc/ (found from this answer on stackoverflow: https://stackoverflow.com/a/43588236).
|
||||
|
||||
3. Install server dependencies again with `go get .` in the `server` folder.
|
||||
|
||||
4. Manually run the server, setting the MODE environment variable in powershell (from the `server` folder)
|
||||
|
||||
```
|
||||
cmd /V /C "set MODE=DEV&& go run ."
|
||||
```
|
||||
|
||||
3. Visit [http://localhost:5173/](http://localhost:5173/) and setup Watcharr.
|
||||
@@ -1,5 +1,6 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
description: Building and installing from source.
|
||||
---
|
||||
|
||||
# From Source
|
||||
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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).
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
description: All environment variables you can use to start your server in a specific way.
|
||||
---
|
||||
|
||||
# Environment Variables
|
||||
|
||||
Currently there is only one, and maybe it will stay that way forever (because the admin interface can handle most settings). A very lonely page right now ;(.
|
||||
|
||||
## `WATCHARR_DATA`
|
||||
|
||||
Make Watcharr use a custom directory for all of its data.
|
||||
|
||||
Defaults to `./data`.
|
||||
@@ -1,18 +1,17 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
description: Setting up video game support.
|
||||
---
|
||||
|
||||
# Games Support (via IGDB/Twitch)
|
||||
|
||||
## Overview
|
||||
|
||||
Game support in Watcharr, currently, must be enabled by you.
|
||||
|
||||
This involves setting up a Twitch developer account (phone number needed for 2FA which is required) and entering application credentials into Watcharr, that can then be used for looking up game info via IGDB.
|
||||
|
||||
P.S. These steps are stolen from [igdb api docs](https://api-docs.igdb.com/#account-creation), I've tried to make it easier to follow with screenshots.
|
||||
|
||||
## Steps
|
||||
## Setup Steps
|
||||
|
||||
### Setup On Twitch
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,28 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
description: Enabling Jellyfin as an authentication provider.
|
||||
---
|
||||
|
||||
# Jellyfin Authentication
|
||||
|
||||
Watcharr can use your Jellyfin server as an authentication provider. There is just one simple step to set this up.
|
||||
|
||||
:::danger Take care
|
||||
|
||||
It is not recommended that this value is changed once it has been set and users have signed up.
|
||||
|
||||
Changing this setting can cause users to be unable to login to the correct account.
|
||||
|
||||
:::
|
||||
|
||||
## Setup Steps
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down to find the Jellyfin Host setting and enter the URL to your Jellyfin server.
|
||||
|
||||

|
||||
|
||||
3. Jellyfin authentication has now been enabled! Go back to the login screen and you will be able to login via Jellyfin.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
description: Enabling Plex as an authentication provider.
|
||||
---
|
||||
|
||||
# Plex Authentication
|
||||
|
||||
Watcharr can use Plex as an authentication provider. There is just one simple step to set this up.
|
||||
|
||||
## Setup Steps
|
||||
|
||||
1. Login to Watcharr with an Admin account and navigate to server settings.
|
||||
|
||||

|
||||
|
||||
2. Scroll down and find the Plex Host setting.
|
||||
|
||||

|
||||
|
||||
3. Enter the URL to your home Plex server.
|
||||
You will see your Plex machine identifier popup below the input after it's saved, if it worked.
|
||||
Only users with access to your library will be able to signup via Plex.
|
||||
|
||||

|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
description: Getting admin privileges for your account without already having access to an account with admin.
|
||||
---
|
||||
|
||||
# Restoring Admin Priviliges
|
||||
|
||||
This guide will help you restore/give admin access to any account without needing an account with admin. If you ever accidentally remove your permissions, this is the easiest way to get them back.
|
||||
|
||||
All you will need is access to the `watcharr.log` file.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Login with the account you'd like to give admin to.
|
||||
|
||||
2. Navigate to the `/request_admin` page manually (ex: `127.0.0.1:3080/request_admin`) and click the `request` button.
|
||||
|
||||

|
||||
|
||||
3. Check your `watcharr.log` file (or simply check `docker compose logs` if using docker compose) and look for the generated admin token (highlighted in the image below).
|
||||
|
||||

|
||||
|
||||
4. Enter the code from the log (you should have been taken here after clicking the request button in step 2).
|
||||
|
||||

|
||||
|
||||
5. Click `Check Token`, if it is correct and still valid (they expire after ~2 minutes or once used), your account will now have admin.
|
||||
@@ -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,104 @@ 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",
|
||||
},
|
||||
{
|
||||
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,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
|
||||
@@ -6339,9 +6339,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz",
|
||||
"integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==",
|
||||
"version": "1.15.6",
|
||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
|
||||
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3"
|
||||
|
||||
# To test prod
|
||||
services:
|
||||
watcharr:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
version: "3"
|
||||
|
||||
services:
|
||||
watcharr:
|
||||
# The :latest tag is used for simplicity, it is recommended
|
||||
|
||||
@@ -1,41 +1,42 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.35.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": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.5.3",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.2.0",
|
||||
"@typescript-eslint/parser": "^7.2.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.1",
|
||||
"svelte": "^4.2.10",
|
||||
"svelte-check": "^3.6.4",
|
||||
"svelte-eslint-parser": "^0.33.1",
|
||||
"svelte-preprocess": "^5.1.3",
|
||||
"tslib": "^2.6.2",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
"name": "watcharr",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"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"
|
||||
|
||||
@@ -56,6 +56,7 @@ type RadarrTestResponse struct {
|
||||
LanguageProfiles []arr.LanguageProfile `json:"languageProfiles"`
|
||||
}
|
||||
|
||||
// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
|
||||
func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
|
||||
sonarr := arr.New(arr.SONARR, &p.Host, &p.Key)
|
||||
qps, err := sonarr.GetQualityProfiles()
|
||||
@@ -76,6 +77,7 @@ func testSonarr(p ArrTestParams) (SonarrTestResponse, error) {
|
||||
return SonarrTestResponse{QualityProfiles: qps, RootFolders: rfs, LanguageProfiles: lps}, nil
|
||||
}
|
||||
|
||||
// Response given to users with PERM_REQUEST_CONTENT - should never include sensitive info
|
||||
func testRadarr(p ArrTestParams) (RadarrTestResponse, error) {
|
||||
radarr := arr.New(arr.RADARR, &p.Host, &p.Key)
|
||||
qps, err := radarr.GetQualityProfiles()
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type ArrType string
|
||||
@@ -37,6 +38,7 @@ type ArrRequest struct {
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
Title string `json:"title"` // content name
|
||||
Year int `json:"year"` // content year
|
||||
TMDBID int `json:"tmdbId"`
|
||||
}
|
||||
|
||||
type SonarrRequest struct {
|
||||
@@ -49,7 +51,6 @@ type SonarrRequest struct {
|
||||
|
||||
type RadarrRequest struct {
|
||||
ArrRequest
|
||||
TMDBID int `json:"tmdbId"`
|
||||
}
|
||||
|
||||
type SonarrSeasons struct {
|
||||
@@ -66,9 +67,9 @@ func New(t ArrType, host *string, key *string) *Arr {
|
||||
}
|
||||
|
||||
func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
|
||||
slog.Info("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
slog.Debug("GetQualityProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []QualityProfile
|
||||
err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
_, err := request(*a.Host, "/qualityprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetQualityProfiles request failed", "service", a.Type, "error", err)
|
||||
return []QualityProfile{}, errors.New("request to service failed")
|
||||
@@ -77,9 +78,9 @@ func (a *Arr) GetQualityProfiles() ([]QualityProfile, error) {
|
||||
}
|
||||
|
||||
func (a *Arr) GetRootFolders() ([]RootFolder, error) {
|
||||
slog.Info("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
slog.Debug("GetRootFolders", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []RootFolder
|
||||
err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
|
||||
_, err := request(*a.Host, "/rootfolder", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetRootFolders request failed", "service", a.Type, "error", err)
|
||||
return []RootFolder{}, errors.New("request to service failed")
|
||||
@@ -88,10 +89,11 @@ func (a *Arr) GetRootFolders() ([]RootFolder, error) {
|
||||
}
|
||||
|
||||
func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
|
||||
slog.Info("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
slog.Debug("GetLangaugeProfiles", "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp []LanguageProfile
|
||||
// languageprofile supposedly deprecated.. but new language endpoint doesnt seem to work.. note probs to switch soon
|
||||
err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
// TODO languages are handled diffferently in Sonarr now, I think we can remove all language stuff, now controlled per profile.
|
||||
_, err := request(*a.Host, "/languageprofile", map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetLangaugeProfiles request failed", "service", a.Type, "error", err)
|
||||
return []LanguageProfile{}, errors.New("request to service failed")
|
||||
@@ -99,7 +101,69 @@ func (a *Arr) GetLangaugeProfiles() ([]LanguageProfile, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// TODO do title! and year?
|
||||
func (a *Arr) RunCommand(name string) (CommandResponse, error) {
|
||||
slog.Debug("RunCommand", "name", name, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
var resp CommandResponse
|
||||
err := requestPost(*a.Host, "/command", *a.Key, map[string]interface{}{"name": name}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("RunCommand request failed", "name", name, "service", a.Type, "error", err)
|
||||
return CommandResponse{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// arrId = movieId/seriesId on radarr/sonarr
|
||||
func (a *Arr) GetQueueDetails(arrId string, resp interface{}) error {
|
||||
slog.Debug("GetQueueDetails", "arrId", arrId, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
p := map[string]string{"apikey": *a.Key}
|
||||
if a.Type == RADARR {
|
||||
p["movieId"] = arrId
|
||||
} else if a.Type == SONARR {
|
||||
p["seriesId"] = arrId
|
||||
} else {
|
||||
return errors.New("invalid arr type")
|
||||
}
|
||||
_, err := request(*a.Host, "/queue/details", p, resp)
|
||||
if err != nil {
|
||||
slog.Error("GetQueueDetails request failed", "arrId", arrId, "service", a.Type, "error", err)
|
||||
return errors.New("request to service failed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get movie/show
|
||||
func (a *Arr) GetContent(arrId int) (MovieSerie, int, error) {
|
||||
slog.Debug("GetContent", "arrId", arrId, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
e := "movie"
|
||||
if a.Type == SONARR {
|
||||
e = "series"
|
||||
}
|
||||
arrIdStr := strconv.Itoa(arrId)
|
||||
var resp MovieSerie
|
||||
respStatusCode, err := request(*a.Host, "/"+e+"/"+arrIdStr, map[string]string{"apikey": *a.Key}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("GetContent request failed", "arrId", arrId, "service", a.Type, "error", err)
|
||||
return MovieSerie{}, respStatusCode, errors.New("request to service failed")
|
||||
}
|
||||
return resp, respStatusCode, nil
|
||||
}
|
||||
|
||||
func (a *Arr) LookupByTmdbId(tmdbId int) ([]MovieSerie, error) {
|
||||
slog.Debug("LookupByTmdbId", "tmdbId", tmdbId, "type", a.Type, "host", *a.Host, "key", *a.Key)
|
||||
e := "movie"
|
||||
if a.Type == SONARR {
|
||||
e = "series"
|
||||
}
|
||||
var resp []MovieSerie
|
||||
tmdbIdStr := strconv.Itoa(tmdbId)
|
||||
_, err := request(*a.Host, "/"+e+"/lookup", map[string]string{"apikey": *a.Key, "term": "tmdb:" + tmdbIdStr}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("LookupByTmdbId request failed", "tmdbId", tmdbId, "service", a.Type, "error", err)
|
||||
return []MovieSerie{}, errors.New("request to service failed")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (a *Arr) BuildAddShowBody(r SonarrRequest) map[string]interface{} {
|
||||
req := map[string]interface{}{
|
||||
"title": r.Title,
|
||||
@@ -135,26 +199,27 @@ func (a *Arr) BuildAddMovieBody(r RadarrRequest) map[string]interface{} {
|
||||
return req
|
||||
}
|
||||
|
||||
func (a *Arr) AddContent(b map[string]interface{}) error {
|
||||
func (a *Arr) AddContent(b map[string]interface{}) (map[string]interface{}, error) {
|
||||
ep := "series"
|
||||
if a.Type == RADARR {
|
||||
ep = "movie"
|
||||
}
|
||||
slog.Debug("AddContent", "type", ep, "body", b)
|
||||
var resp interface{}
|
||||
var resp map[string]interface{}
|
||||
err := requestPost(*a.Host, "/"+ep, *a.Key, b, &resp)
|
||||
if err != nil {
|
||||
slog.Error("AddContent request failed", "service", a.Type, "error", err)
|
||||
return errors.New("request to service failed")
|
||||
return map[string]interface{}{}, errors.New("request to service failed")
|
||||
}
|
||||
return nil
|
||||
slog.Debug("AddContent", "type", ep, "created_id", resp["id"])
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func request(host string, ep string, p map[string]string, resp interface{}) error {
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
func request(host string, ep string, p map[string]string, resp interface{}) (int, error) {
|
||||
slog.Debug("arrAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse(host)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
return 0, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
@@ -172,23 +237,26 @@ func request(host string, ep string, p map[string]string, resp interface{}) erro
|
||||
// Run get request
|
||||
res, err := http.Get(base.String())
|
||||
if err != nil {
|
||||
return err
|
||||
if res != nil {
|
||||
return res.StatusCode, err
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
return res.StatusCode, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("arr non 200 status code:", "status_code", res.StatusCode)
|
||||
return errors.New(string(body))
|
||||
return res.StatusCode, errors.New(string(body))
|
||||
}
|
||||
// slog.Info("", "body", body)
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
return res.StatusCode, err
|
||||
}
|
||||
return nil
|
||||
return res.StatusCode, nil
|
||||
}
|
||||
|
||||
func requestPost(host string, ep string, key string, p map[string]interface{}, resp interface{}) error {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package arr
|
||||
|
||||
import "time"
|
||||
|
||||
type QualityProfile struct {
|
||||
Name string `json:"name"`
|
||||
UpgradeAllowed bool `json:"upgradeAllowed"`
|
||||
@@ -43,3 +45,99 @@ type LanguageProfile struct {
|
||||
} `json:"languages"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type ArrCommandRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type CommandResponse struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
CommandName string `json:"commandName"`
|
||||
Message string `json:"message"`
|
||||
Body struct {
|
||||
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
|
||||
LastExecutionTime time.Time `json:"lastExecutionTime"`
|
||||
LastStartTime time.Time `json:"lastStartTime"`
|
||||
Trigger string `json:"trigger"`
|
||||
SuppressMessages bool `json:"suppressMessages"`
|
||||
ClientUserAgent string `json:"clientUserAgent"`
|
||||
} `json:"body"`
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result"`
|
||||
Queued time.Time `json:"queued"`
|
||||
Started time.Time `json:"started"`
|
||||
Ended time.Time `json:"ended"`
|
||||
Duration struct {
|
||||
Ticks int `json:"ticks"`
|
||||
} `json:"duration"`
|
||||
Exception string `json:"exception"`
|
||||
Trigger string `json:"trigger"`
|
||||
ClientUserAgent string `json:"clientUserAgent"`
|
||||
StateChangeTime time.Time `json:"stateChangeTime"`
|
||||
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
|
||||
UpdateScheduledTask bool `json:"updateScheduledTask"`
|
||||
LastExecutionTime time.Time `json:"lastExecutionTime"`
|
||||
}
|
||||
|
||||
type QueueDetail struct {
|
||||
Languages []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"languages"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Resolution int `json:"resolution"`
|
||||
Modifier string `json:"modifier"`
|
||||
} `json:"quality"`
|
||||
Revision struct {
|
||||
Version int `json:"version"`
|
||||
Real int `json:"real"`
|
||||
IsRepack bool `json:"isRepack"`
|
||||
} `json:"revision"`
|
||||
} `json:"quality"`
|
||||
Size float64 `json:"size"`
|
||||
Title string `json:"title"`
|
||||
SizeLeft float64 `json:"sizeleft"`
|
||||
TimeLeft string `json:"timeleft"`
|
||||
EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"`
|
||||
Added time.Time `json:"added"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
StatusMessages []interface{} `json:"statusMessages"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DownloadClient string `json:"downloadClient"`
|
||||
DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"`
|
||||
Indexer string `json:"indexer"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type RadarrQueueDetails []struct {
|
||||
QueueDetail
|
||||
MovieID int `json:"movieId"`
|
||||
}
|
||||
|
||||
type SonarrQueueDetails []struct {
|
||||
QueueDetail
|
||||
SeriesID int `json:"seriesId"`
|
||||
EpisodeID int `json:"episodeId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeHasFile bool `json:"episodeHasFile"`
|
||||
}
|
||||
|
||||
// From `GET /movie/{id}` or `GET /series/{id}`.
|
||||
// Not all fields described here, just the wanted ones.
|
||||
type MovieSerie struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"originalTitle"`
|
||||
ID int `json:"id"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
IsAvailable bool `json:"isAvailable"`
|
||||
Added time.Time `json:"added"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
)
|
||||
|
||||
type ArrDetailsResponse struct {
|
||||
Progress int `json:"progress"`
|
||||
EstimatedCompletionTime time.Time `json:"estimatedCompletionTime"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
}
|
||||
|
||||
type SonarrDetailsResponseItem struct {
|
||||
ArrDetailsResponse
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type SonarrDetailsResponse struct {
|
||||
// Overall progress
|
||||
Progress int `json:"progress"`
|
||||
// Overall status
|
||||
Status string `json:"status"`
|
||||
// Each downloading episode
|
||||
Items []SonarrDetailsResponseItem `json:"items"`
|
||||
}
|
||||
|
||||
func getRadarrQueueDetails(serverName string, arrId string) (*ArrDetailsResponse, error) {
|
||||
server, err := getRadarr(serverName)
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to get server", "error", err)
|
||||
return &ArrDetailsResponse{}, errors.New("failed to get server")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
// Run refresh downloads, likely won't be refreshed in time before we run GetQueueDetails below,
|
||||
// but if the user calls this again, it should be, which is better than waiting a whole minute for
|
||||
// refresh task to run automatically. This exists until a better solution is thought of.
|
||||
_, err = radarr.RunCommand("RefreshMonitoredDownloads")
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to refresh monitored downloads.", "error", err)
|
||||
}
|
||||
resp := arr.RadarrQueueDetails{}
|
||||
err = radarr.GetQueueDetails(arrId, &resp)
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to add content", "error", err)
|
||||
return &ArrDetailsResponse{}, errors.New("failed to add content")
|
||||
}
|
||||
if len(resp) <= 0 {
|
||||
slog.Error("getRadarrQueueDetails: No results in response")
|
||||
return &ArrDetailsResponse{}, errors.New("no details found")
|
||||
}
|
||||
adr := ArrDetailsResponse{}
|
||||
adr.Progress = int(math.Round((1 - (resp[0].SizeLeft / resp[0].Size)) * 100))
|
||||
adr.EstimatedCompletionTime = resp[0].EstimatedCompletionTime
|
||||
adr.TrackedDownloadStatus = resp[0].TrackedDownloadStatus
|
||||
adr.TrackedDownloadState = resp[0].TrackedDownloadState
|
||||
adr.Status = resp[0].Status
|
||||
return &adr, nil
|
||||
}
|
||||
|
||||
func getSonarrQueueDetails(serverName string, arrId string) (*SonarrDetailsResponse, error) {
|
||||
server, err := getSonarr(serverName)
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to get server", "error", err)
|
||||
return &SonarrDetailsResponse{}, errors.New("failed to get server")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
// Run refresh downloads, likely won't be refreshed in time before we run GetQueueDetails below,
|
||||
// but if the user calls this again, it should be, which is better than waiting a whole minute for
|
||||
// refresh task to run automatically. This exists until a better solution is thought of.
|
||||
_, err = sonarr.RunCommand("RefreshMonitoredDownloads")
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to refresh monitored downloads.", "error", err)
|
||||
}
|
||||
resp := arr.SonarrQueueDetails{}
|
||||
err = sonarr.GetQueueDetails(arrId, &resp)
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to add content", "error", err)
|
||||
return &SonarrDetailsResponse{}, errors.New("failed to add content")
|
||||
}
|
||||
if len(resp) <= 0 {
|
||||
slog.Error("getSonarrQueueDetails: No results in response")
|
||||
return &SonarrDetailsResponse{}, errors.New("no details found")
|
||||
}
|
||||
dr := SonarrDetailsResponse{}
|
||||
totalSize := 0.0
|
||||
totalSizeLeft := 0.0
|
||||
for _, v := range resp {
|
||||
dr.Items = append(dr.Items, SonarrDetailsResponseItem{
|
||||
Title: "TODO :(", // TODO: Will need to do another request to get all episodes so we can get real episode names from EpisodeID
|
||||
ArrDetailsResponse: ArrDetailsResponse{
|
||||
Progress: int(math.Round((1 - (v.SizeLeft / v.Size)) * 100)),
|
||||
EstimatedCompletionTime: v.EstimatedCompletionTime,
|
||||
TrackedDownloadStatus: v.TrackedDownloadStatus,
|
||||
TrackedDownloadState: v.TrackedDownloadState,
|
||||
Status: v.Status,
|
||||
},
|
||||
})
|
||||
totalSize += v.Size
|
||||
totalSizeLeft += v.SizeLeft
|
||||
}
|
||||
dr.Progress = int(math.Round((1 - (totalSizeLeft / totalSize)) * 100))
|
||||
// HACK: Statuses are not great, no easy way to get a good status, since there are multiple items to consider.
|
||||
// At some point, we could add extra checks (ex. if all items (episodes) are paused, overall status can be set to paused).
|
||||
// For now, this is good enough.
|
||||
dr.Status = "requested"
|
||||
if dr.Progress > 0 {
|
||||
dr.Status = "downloading"
|
||||
}
|
||||
return &dr, nil
|
||||
}
|
||||
|
||||
// Refresh download queues for our sonarr/radarr servers.
|
||||
// If the queues don't refresh regularly, our queue detail
|
||||
// calls will just always return the same info.
|
||||
func refreshArrQueues() {
|
||||
slog.Debug("refreshArrQueues: Refreshing queues for all configured arr servers.")
|
||||
// We don't care about responses, errors will be logged by the RunCommand func.
|
||||
for _, v := range Config.RADARR {
|
||||
radarr := arr.New(arr.RADARR, &v.Host, &v.Key)
|
||||
radarr.RunCommand("RefreshMonitoredDownloads")
|
||||
}
|
||||
for _, v := range Config.SONARR {
|
||||
sonarr := arr.New(arr.SONARR, &v.Host, &v.Key)
|
||||
sonarr.RunCommand("RefreshMonitoredDownloads")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArrRequestStatus string
|
||||
|
||||
const (
|
||||
// Pending approval from an admin.
|
||||
ARR_REQUEST_PENDING ArrRequestStatus = "PENDING"
|
||||
// Request has been approved and should be added to sonarr/radarr.
|
||||
ARR_REQUEST_APPROVED ArrRequestStatus = "APPROVED"
|
||||
ARR_REQUEST_AUTO_APPROVED ArrRequestStatus = "AUTO_APPROVED"
|
||||
// Request has been denied, not adding content.
|
||||
ARR_REQUEST_DENIED ArrRequestStatus = "DENIED"
|
||||
// Content was found on sonarr/radarr already, nothing needs to be done.
|
||||
ARR_REQUEST_FOUND ArrRequestStatus = "FOUND"
|
||||
)
|
||||
|
||||
type ArrRequest struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
// Username of `User`.
|
||||
// We don't want to send back the entire user object, just their name.
|
||||
// Not stored in DB, only used for our response from api.
|
||||
Username string `json:"username" gorm:"-"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
// Server names are used as an identifier
|
||||
ServerName string `json:"serverName" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
// Sonarr/Radarrs seriesId/movieId
|
||||
ArrID int `json:"arrId"`
|
||||
// Tracked request status
|
||||
Status ArrRequestStatus `json:"status" gorm:"default:PENDING"`
|
||||
// Full request made by user (arr.SonarrRequest / arr.RadarrRequest)
|
||||
// so we know how to fulfil the request if approved.
|
||||
RequestJson string `json:"requestJson"`
|
||||
}
|
||||
|
||||
func deleteArrRequest(db *gorm.DB, id uint) error {
|
||||
resp := db.Delete(&ArrRequest{ID: id})
|
||||
if resp.Error != nil {
|
||||
slog.Error("deleteArrRequest: Failed to remove from db", "error", resp.Error)
|
||||
return errors.New("failed when removing request")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Gets all requests.
|
||||
func getArrRequests(db *gorm.DB) ([]ArrRequest, error) {
|
||||
var req []ArrRequest
|
||||
resp := db.Preload("Content").Preload("User").Find(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequests: Failed to search for requests in db", "error", resp.Error)
|
||||
return []ArrRequest{}, errors.New("failed to find requests")
|
||||
}
|
||||
for i := range req {
|
||||
req[i].Username = req[i].User.Username
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getArrRequest(db *gorm.DB, requestId uint) (ArrRequest, error) {
|
||||
var req ArrRequest
|
||||
resp := db.Where("id = ?", requestId).Take(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequest: Failed to search for request in db", "error", resp.Error)
|
||||
return ArrRequest{}, errors.New("failed to find request")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getArrRequestByTmdbId(db *gorm.DB, contentType ContentType, tmdbId int) (ArrRequest, error) {
|
||||
var req ArrRequest
|
||||
resp := db.Joins("JOIN contents ON contents.id = arr_requests.content_id AND contents.tmdb_id = ? AND contents.type = ?", tmdbId, contentType).Find(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequestByTmdbId: Failed to search for request in db", "error", resp.Error)
|
||||
return ArrRequest{}, errors.New("failed to find request")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func createArrRequest(db *gorm.DB, userId uint, serverName string, contentType ContentType, tmdbId int, reqJson string) (*ArrRequest, error) {
|
||||
content, err := getOrCacheContent(db, contentType, tmdbId)
|
||||
if err != nil {
|
||||
slog.Error("createArrRequest: getOrCacheContent errored.")
|
||||
return &ArrRequest{}, err
|
||||
}
|
||||
req := ArrRequest{UserID: userId, ServerName: serverName, ContentID: &content.ID, RequestJson: reqJson}
|
||||
resp := db.Create(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("createArrRequest: Failed when inserting request into db.", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when adding request")
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func createSonarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.SonarrRequest) (*ArrRequest, error) {
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed to get server", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to get server")
|
||||
}
|
||||
reqJson, err := json.Marshal(ur)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when processing request")
|
||||
}
|
||||
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
|
||||
arrReq, err := createArrRequest(db, userId, ur.ServerName, SHOW, ur.TMDBID, string(reqJson[:]))
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed when creating arr request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when creating request")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
// 1. Lookup on Sonarr to check if the show has already been added (via method other than watcharr).
|
||||
lookupRes, err := sonarr.LookupByTmdbId(ur.TMDBID)
|
||||
if err == nil && len(lookupRes) == 1 {
|
||||
slog.Debug("createSonarrRequest: Lookup returned results.")
|
||||
found := lookupRes[0] // There should only be one result when looking up by id.
|
||||
// If it has an ID, then it will have already been added to Sonarr.
|
||||
if found.ID != 0 {
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
} else {
|
||||
slog.Debug("createSonarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
|
||||
arrReq.ArrID = found.ID
|
||||
return arrReq, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. If user has auto approve perms, add movie to sonarr.
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
slog.Debug("createSonarrRequest: User has auto approve permission.. sending request to Sonarr.")
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed to add content", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("createSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return &ArrRequest{}, errors.New("failed to get arr id")
|
||||
}
|
||||
arrReq.ArrID = int(arrId)
|
||||
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
|
||||
}
|
||||
return arrReq, nil
|
||||
}
|
||||
|
||||
func createRadarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.RadarrRequest) (*ArrRequest, error) {
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed to get server", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to get server")
|
||||
}
|
||||
reqJson, err := json.Marshal(ur)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when processing request")
|
||||
}
|
||||
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
|
||||
arrReq, err := createArrRequest(db, userId, ur.ServerName, MOVIE, ur.TMDBID, string(reqJson[:]))
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when creating arr request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when creating request")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
// 1. Lookup on Radarr to check if the movie has already been added (via method other than watcharr).
|
||||
lookupRes, err := radarr.LookupByTmdbId(ur.TMDBID)
|
||||
if err == nil && len(lookupRes) == 1 {
|
||||
slog.Debug("createRadarrRequest: Lookup returned results.")
|
||||
found := lookupRes[0] // There should only be one result when looking up by id.
|
||||
// If it has an ID, then it will have already been added to Radarr.
|
||||
if found.ID != 0 {
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
} else {
|
||||
slog.Debug("createRadarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
|
||||
arrReq.ArrID = found.ID
|
||||
return arrReq, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2. If user has auto approve perms, add movie to radarr.
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
slog.Debug("createRadarrRequest: User has auto approve permission.. sending request to Radarr.")
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed to add content", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("createRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return &ArrRequest{}, errors.New("failed to get arr id")
|
||||
}
|
||||
arrReq.ArrID = int(arrId)
|
||||
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
|
||||
}
|
||||
return arrReq, nil
|
||||
}
|
||||
|
||||
func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
if requestId == 0 {
|
||||
slog.Error("sonarr info: No request id provided")
|
||||
return arr.MovieSerie{}, errors.New("no request id provided")
|
||||
}
|
||||
arrRequest, err := getArrRequest(db, requestId)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
server, err := getRadarr(arrRequest.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
resp, respStatusCode, err := radarr.GetContent(arrRequest.ArrID)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get info", "error", err)
|
||||
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
slog.Error("radarr info: 404 returned.. content must've been removed.. removing request.")
|
||||
err := deleteArrRequest(db, arrRequest.ID)
|
||||
if err != nil {
|
||||
return arr.MovieSerie{}, errors.New("failed to delete request for removed content")
|
||||
} else {
|
||||
return arr.MovieSerie{}, errors.New("request deleted")
|
||||
}
|
||||
}
|
||||
return arr.MovieSerie{}, errors.New("failed to get info")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func getSonarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
if requestId == 0 {
|
||||
slog.Error("sonarr info: No request id provided")
|
||||
return arr.MovieSerie{}, errors.New("no request id provided")
|
||||
}
|
||||
arrRequest, err := getArrRequest(db, requestId)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
server, err := getSonarr(arrRequest.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
resp, respStatusCode, err := sonarr.GetContent(arrRequest.ArrID)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get info", "error", err)
|
||||
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
slog.Error("sonarr info: 404 returned.. content must've been removed.. removing request.")
|
||||
err := deleteArrRequest(db, arrRequest.ID)
|
||||
if err != nil {
|
||||
return arr.MovieSerie{}, errors.New("failed to delete request for removed content")
|
||||
} else {
|
||||
return arr.MovieSerie{}, errors.New("request deleted")
|
||||
}
|
||||
}
|
||||
return arr.MovieSerie{}, errors.New("failed to get info")
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Deny an arr request
|
||||
func denyArrRequest(db *gorm.DB, id uint) error {
|
||||
resp := db.Model(&ArrRequest{}).Where("id = ?", id).Update("status", ARR_REQUEST_DENIED)
|
||||
if resp.Error != nil {
|
||||
slog.Error("denyArrRequest: Failed to update status to denied", "error", resp.Error)
|
||||
return errors.New("failed when updating request status")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Approve radarr movie
|
||||
func approveRadarrRequest(db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, error) {
|
||||
_, err := getArrRequest(db, reqId)
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to get request from db", "error", err)
|
||||
return 0, errors.New("failed to get request")
|
||||
}
|
||||
// Get server in request
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to get server", "error", err)
|
||||
return 0, errors.New("failed to get server")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to add content", "error", err)
|
||||
return 0, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to update request in db", "error", err)
|
||||
return 0, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("approveRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return 0, errors.New("content added, but failed to get arr id")
|
||||
}
|
||||
return int(arrId), nil
|
||||
}
|
||||
|
||||
// Approve sonarr movie
|
||||
func approveSonarrRequest(db *gorm.DB, reqId uint, ur arr.SonarrRequest) (int, error) {
|
||||
_, err := getArrRequest(db, reqId)
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to get request from db", "error", err)
|
||||
return 0, errors.New("failed to get request")
|
||||
}
|
||||
// Get server in request
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to get server", "error", err)
|
||||
return 0, errors.New("failed to get server")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to add content", "error", err)
|
||||
return 0, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to update request in db", "error", err)
|
||||
return 0, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("approveSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return 0, errors.New("failed to get arr id")
|
||||
}
|
||||
return int(arrId), nil
|
||||
}
|
||||
@@ -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
|
||||
@@ -40,6 +41,7 @@ const (
|
||||
PERM_NONE int = 1 << iota
|
||||
PERM_ADMIN
|
||||
PERM_REQUEST_CONTENT
|
||||
PERM_REQUEST_CONTENT_AUTO_APPROVE
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
@@ -61,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
|
||||
}
|
||||
@@ -88,6 +93,15 @@ type UserSettings struct {
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
// 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.
|
||||
@@ -147,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 {
|
||||
@@ -193,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)
|
||||
@@ -254,6 +270,21 @@ func AdminRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// Specific perm only middleware (use after AuthRequired with extra info!)
|
||||
func PermRequired(perm int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if hasPermission(perms, perm) {
|
||||
slog.Debug("PermRequired: User has permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("PermRequired: User denied permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
|
||||
if !Config.SIGNUP_ENABLED {
|
||||
slog.Warn("Register called, but signing up is disabled.")
|
||||
@@ -275,6 +306,8 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
user.Permissions = initialPerm
|
||||
}
|
||||
|
||||
user.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
res := db.Create(&user)
|
||||
if res.Error != nil {
|
||||
// If error is because unique contraint failed.. user already exists
|
||||
@@ -406,6 +439,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
dbUser.Username = resp.User.Name
|
||||
dbUser.Type = JELLYFIN_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
@@ -447,28 +481,28 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
|
||||
return AuthResponse{}, errors.New("data is missing from the plex account response")
|
||||
}
|
||||
// Get users auth token against our home plex server.
|
||||
// If no auth token, assume they don't have access to our plex server.
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil || homeAuthToken == "" {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for user! If not because the request failed, then ensure the user has access to our home servers library.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser := new(User)
|
||||
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
|
||||
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
|
||||
if err := plexUserHasAccessToPlexHost(lr.AuthToken); err != nil {
|
||||
slog.Error("loginPlex: Cannot register Plex user. Failed to verify they have access to our home plex server.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser.Username = account.Username
|
||||
dbUser.Type = PLEX_USER
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for the new user! User will still be created, a re-login may fix this issue.", "error", err)
|
||||
}
|
||||
dbUser.UserServices = append(dbUser.UserServices, UserServices{
|
||||
Name: "plex",
|
||||
ClientID: strconv.FormatUint(account.Id, 10),
|
||||
AuthToken: lr.AuthToken,
|
||||
AuthToken2: homeAuthToken,
|
||||
})
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
|
||||
@@ -480,10 +514,6 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
} else {
|
||||
// If user exists.. update their access tokens in db
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to get home server auth token!", "error", err)
|
||||
}
|
||||
for i, v := range dbUser.UserServices {
|
||||
if v.Name == "plex" {
|
||||
slog.Info("loginPlex: Found plex user service.. attemping to update")
|
||||
@@ -559,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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"reflect"
|
||||
"strconv"
|
||||
|
||||
"github.com/robfig/go-cache"
|
||||
)
|
||||
|
||||
// Extension of the `.Get` method for `go-cache`.
|
||||
// This method will simplify our usage so we don't need
|
||||
// to type assert everywhere, this method will handle
|
||||
// everything related to getting the value from cache.
|
||||
// Returns `true` if `rv` was set to the cached value.
|
||||
// Returns `false` if we couldn't get anything from cache.
|
||||
func GetCache(c *cache.Cache, k string, rv any) bool {
|
||||
if val, found := c.Get(k); found {
|
||||
v := reflect.ValueOf(rv)
|
||||
if v.Type().Kind() == reflect.Ptr && v.Elem().CanSet() {
|
||||
v.Elem().Set(reflect.ValueOf(val))
|
||||
slog.Debug("cachefunc: Cache found.", "key", k)
|
||||
return true
|
||||
}
|
||||
slog.Error("cachefunc: Cache not set", "key", k)
|
||||
return false
|
||||
}
|
||||
slog.Debug("cachefunc: Cache not found", "key", k)
|
||||
return false
|
||||
}
|
||||
|
||||
// Create a cache key for our in-mem cache.
|
||||
//
|
||||
// `name` should be the name of the function response we are caching.
|
||||
//
|
||||
// `...u` can be any amount of values that will make this key unique.
|
||||
// Currently supports types:
|
||||
// - `string`
|
||||
// - `map[string]string`
|
||||
// - `int`
|
||||
func CreateCacheKey(name string, u ...any) string {
|
||||
str := name
|
||||
appnd := func(s string) {
|
||||
str += "-" + s
|
||||
}
|
||||
for _, v := range u {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
appnd(vv)
|
||||
case map[string]string:
|
||||
for k, e := range vv {
|
||||
appnd(k + "_" + e)
|
||||
}
|
||||
case int:
|
||||
appnd(strconv.Itoa(vv))
|
||||
default:
|
||||
// This should never happen, but incase of unknown
|
||||
// value passed, hopefully this should make it easier
|
||||
// to catch in logs.
|
||||
str = str + "KEYTYPEERR"
|
||||
}
|
||||
}
|
||||
return str
|
||||
}
|
||||
@@ -6,23 +6,37 @@ import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var DataPath = "./data"
|
||||
var DataPath = func() string {
|
||||
path := os.Getenv("WATCHARR_DATA")
|
||||
if path == "" {
|
||||
path = "./data"
|
||||
}
|
||||
return path
|
||||
}()
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
JWT_SECRET string `json:",omitempty"`
|
||||
|
||||
// Default country for new users. This is used to set the default
|
||||
// region to get correct content streaming providers.
|
||||
DEFAULT_COUNTRY string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to your Jellyfin install
|
||||
// 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
|
||||
@@ -40,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.
|
||||
@@ -60,7 +81,9 @@ type ServerConfig struct {
|
||||
func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
return 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,
|
||||
@@ -74,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{}
|
||||
@@ -82,7 +134,7 @@ var (
|
||||
// Read config file
|
||||
// Calls generateConfig if file doesn't exist
|
||||
func readConfig() error {
|
||||
cfg, err := os.Open("./data/watcharr.json")
|
||||
cfg, err := os.Open(path.Join(DataPath, "watcharr.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Info("Config file doesn't exist... generating.")
|
||||
@@ -119,14 +171,15 @@ func generateConfig() error {
|
||||
cfg := ServerConfig{
|
||||
JWT_SECRET: key,
|
||||
// Other defaults..
|
||||
SIGNUP_ENABLED: true,
|
||||
DEFAULT_COUNTRY: "US",
|
||||
SIGNUP_ENABLED: true,
|
||||
}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
Config = cfg
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
// Update server config property
|
||||
@@ -137,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" {
|
||||
@@ -144,10 +199,17 @@ func updateConfig(k string, v any) error {
|
||||
} else if k == "DEBUG" {
|
||||
Config.DEBUG = v.(bool)
|
||||
setLoggingLevel()
|
||||
} else if k == "DEFAULT_COUNTRY" {
|
||||
Config.DEFAULT_COUNTRY = v.(string)
|
||||
} 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
|
||||
@@ -156,7 +218,7 @@ func writeConfig() error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile("./data/watcharr.json", barej, 0755)
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
type ServerFeatures struct {
|
||||
@@ -173,16 +235,13 @@ func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
// https://github.com/sbondCo/Watcharr/issues/211
|
||||
// Currently requesting permissions have not been setup, only admins for now.
|
||||
if !hasPermission(userPerms, PERM_ADMIN) {
|
||||
return f
|
||||
}
|
||||
if len(Config.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(Config.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT) {
|
||||
if len(Config.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(Config.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/go-cache"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -15,8 +18,13 @@ type ContentType string
|
||||
const (
|
||||
MOVIE ContentType = "movie"
|
||||
SHOW ContentType = "tv"
|
||||
// Show episode
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
// inmemory content cache
|
||||
var ContentStore = cache.New(time.Hour*24, time.Minute)
|
||||
|
||||
// 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"`
|
||||
@@ -85,7 +93,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("./data/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())
|
||||
}
|
||||
@@ -170,6 +178,50 @@ func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Get content from our cache, or cache it if it doesn't exist.
|
||||
func getOrCacheContent(db *gorm.DB, contentType ContentType, tmdbId int) (Content, error) {
|
||||
var content Content
|
||||
// Look in db for content.
|
||||
db.Where("type = ? AND tmdb_id = ?", contentType, tmdbId).Find(&content)
|
||||
// Create content if not found from our db.
|
||||
if content == (Content{}) {
|
||||
slog.Debug("Content not in db, fetching...", "type", contentType, "tmdbId", tmdbId)
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(contentType)+"/"+strconv.Itoa(tmdbId), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: content tmdb api request failed", "error", err)
|
||||
return Content{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
if contentType == "movie" {
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Content{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: failed to cache movie content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
} else {
|
||||
c := new(TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return Content{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: failed to cache tv content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
@@ -194,18 +246,124 @@ 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
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchContent", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchContent: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
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")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
resp := new(TMDBSearchMoviesResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchMovies", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
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"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
resp := new(TMDBSearchShowsResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchTv", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
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"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
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,
|
||||
TMDBPageFields: TMDBPageFields{
|
||||
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)
|
||||
cacheKey := CreateCacheKey("movieDetails", id, country, rParams)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("movieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
@@ -213,6 +371,7 @@ func movieDetails(db *gorm.DB, id string, country string, rParams map[string]str
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -226,8 +385,18 @@ func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBShowDetails, error) {
|
||||
func tvDetails(
|
||||
db *gorm.DB,
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (TMDBShowDetails, error) {
|
||||
cacheKey := CreateCacheKey("tvDetails", id, country, rParams)
|
||||
resp := new(TMDBShowDetails)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("tvDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
@@ -235,6 +404,7 @@ func tvDetails(db *gorm.DB, id string, country string, rParams map[string]string
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -248,13 +418,20 @@ 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) {
|
||||
cacheKey := CreateCacheKey("seasonDetails", tvId, seasonNumber)
|
||||
resp := new(TMDBSeasonDetails)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
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")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -279,55 +456,101 @@ func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
}
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
cacheKey := CreateCacheKey("discoverMovies")
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
cacheKey := CreateCacheKey("discoverTv")
|
||||
resp := new(TMDBDiscoverShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func allTrending() (TMDBTrendingAll, error) {
|
||||
cacheKey := CreateCacheKey("allTrending")
|
||||
resp := new(TMDBTrendingAll)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("allTrending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func upcomingMovies() (TMDBUpcomingMovies, error) {
|
||||
cacheKey := CreateCacheKey("upcomingMovies")
|
||||
resp := new(TMDBUpcomingMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
cacheKey := CreateCacheKey("upcomingTv")
|
||||
resp := new(TMDBUpcomingShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1", "first_air_date.gte": mind, "first_air_date.lte": maxd, "sort_by": "popularity.desc", "with_type": "2|3"}, &resp)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{
|
||||
"page": "1",
|
||||
"first_air_date.gte": mind,
|
||||
"first_air_date.lte": maxd,
|
||||
"sort_by": "popularity.desc",
|
||||
"with_type": "2|3",
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func regions() (TMDBRegions, error) {
|
||||
resp := new(TMDBRegions)
|
||||
err := tmdbRequest("/watch/providers/regions", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete regions request!", "error", err.Error())
|
||||
return TMDBRegions{}, errors.New("failed to complete regions request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
// All the functions that help us turn a TMDB response struct
|
||||
// into one that will also include Watched data.
|
||||
// This process is very verbose. As far as I am aware, golangs
|
||||
// generics are not mature (powerful) enough to support us doing
|
||||
// this all with one function.
|
||||
//
|
||||
// TODO When possible look at turning all these funcs into one that
|
||||
// is reuable for any tmdb search response type.
|
||||
//
|
||||
// Each function will basically perform these simple steps:
|
||||
// 1. Repackage tmdb response so we can add Watched data to
|
||||
// 2. Get all watched data for the tmdb results
|
||||
// 4. Add any watched data to our new *WithWatched struct
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func searchContentAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMultiResponse,
|
||||
) TMDBSearchMultiResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMultiResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMultiResultsWithWatched{
|
||||
TMDBSearchMultiResults: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMoviesResponse,
|
||||
) TMDBSearchMoviesResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMoviesResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMovieResultWithWatched{
|
||||
TMDBSearchMovieResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchShowsResponse,
|
||||
) TMDBSearchShowsResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchShowsResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchShowsResultWithWatched{
|
||||
TMDBSearchShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func tvDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBShowDetails,
|
||||
) TMDBShowDetailsWithWatched {
|
||||
withWatchedResp := TMDBShowDetailsWithWatched{}
|
||||
withWatchedResp.TMDBShowDetailsBase = content.TMDBShowDetailsBase
|
||||
// Append watched list entry if exists
|
||||
if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), SHOW); err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
withWatchedResp.FailedToGetWatched = true
|
||||
}
|
||||
} else {
|
||||
withWatchedResp.Watched = &watchedEntry
|
||||
}
|
||||
// Add similar content with any watched entries
|
||||
similarContentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Similar.Results {
|
||||
withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBShowSimilarResultWithWatched{
|
||||
TMDBShowSimilarResult: v,
|
||||
})
|
||||
similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Similar.Results {
|
||||
if vv.ID == v.Content.TmdbID && string(SHOW) == string(v.Content.Type) {
|
||||
withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func allTrendingAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBTrendingAll,
|
||||
) TMDBTrendingAllWithWatched {
|
||||
withWatchedResp := TMDBTrendingAllWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBTrendingAllResultWithWatched{
|
||||
TMDBTrendingAllResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverShows,
|
||||
) TMDBDiscoverShowsWithWatched {
|
||||
withWatchedResp := TMDBDiscoverShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverShowsResultWithWatched{
|
||||
TMDBDiscoverShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingShows,
|
||||
) TMDBUpcomingShowsWithWatched {
|
||||
withWatchedResp := TMDBUpcomingShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingShowsResultWithWatched{
|
||||
TMDBUpcomingShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverMovies,
|
||||
) TMDBDiscoverMoviesWithWatched {
|
||||
withWatchedResp := TMDBDiscoverMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverMoviesResultWithWatched{
|
||||
TMDBDiscoverMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingMovies,
|
||||
) TMDBUpcomingMoviesWithWatched {
|
||||
withWatchedResp := TMDBUpcomingMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingMoviesResultWithWatched{
|
||||
TMDBUpcomingMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, a
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND game_id = ?", userId, watched.GameID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, a
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addPlayed: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND game_id = ?", userId, watched.GameID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
|
||||
@@ -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.0
|
||||
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.21.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.7
|
||||
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.2 // 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.1.1 // 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.18.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.2 h1:ywfwo0a/3j9HR8wsYGWsIWl2mvRsI950HyoxiBERw5A=
|
||||
github.com/bytedance/sonic v1.11.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/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.0 h1:wZX2wuZ0o7rV2/1i7gb4Jn+gW7HBqaP91fizJkBUJOA=
|
||||
github.com/gin-contrib/cors v1.7.0/go.mod h1:cI+h6iOAyxKRtUtC6iF/Si1KSFvGm/gK+kshxlCi8ro=
|
||||
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.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
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=
|
||||
@@ -89,27 +94,29 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
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.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
|
||||
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
|
||||
golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc=
|
||||
golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
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.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
|
||||
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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=
|
||||
@@ -118,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.7 h1:VsD6acwRjz2zFxGO50gPO6AkNs7KKnvfzUjHQhZDz/A=
|
||||
gorm.io/gorm v1.25.7/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=
|
||||
|
||||
@@ -27,14 +27,19 @@ var (
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating int8 `json:"rating"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
Name string `json:"name"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
|
||||
Tags []TagAddRequest `json:"tags"`
|
||||
ImdbID string `json:"imdbId"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -67,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")
|
||||
@@ -170,5 +221,93 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
}
|
||||
}
|
||||
}
|
||||
// Add all activity passed in.
|
||||
// Probably was is a Watcharr export being imported, so it'll have all it's activity too.
|
||||
if len(ar.Activity) > 0 {
|
||||
slog.Debug("successfulImport: Importing activity")
|
||||
for i, v := range ar.Activity {
|
||||
activityDate := ar.Activity[i].CustomDate
|
||||
if activityDate == nil || activityDate.IsZero() {
|
||||
activityDate = &ar.Activity[i].CreatedAt
|
||||
}
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add imported activity.", "full_object", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Import watched seasons, if any
|
||||
if len(ar.WatchedSeason) > 0 {
|
||||
slog.Debug("successfulImport: Importing watched seasons")
|
||||
for _, v := range ar.WatchedSeason {
|
||||
ws, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: v.SeasonNumber,
|
||||
Status: v.Status,
|
||||
Rating: v.Rating,
|
||||
addActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched season.", "error", err)
|
||||
continue
|
||||
}
|
||||
w.WatchedSeasons = ws.WatchedSeasons
|
||||
}
|
||||
}
|
||||
// Import watched episodes, if any
|
||||
if len(ar.WatchedEpisodes) > 0 {
|
||||
slog.Debug("successfulImport: Importing watched episodes")
|
||||
for _, v := range ar.WatchedEpisodes {
|
||||
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: v.SeasonNumber,
|
||||
EpisodeNumber: v.EpisodeNumber,
|
||||
Status: v.Status,
|
||||
Rating: v.Rating,
|
||||
addActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched episodes.", "error", err)
|
||||
continue
|
||||
}
|
||||
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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -2,19 +2,80 @@ package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Location middleware
|
||||
func WhereaboutsRequired() gin.HandlerFunc {
|
||||
// TODO: This should also take into account a user
|
||||
// preference (of location.. which also needs to be
|
||||
// added) and server preference (env var). The US is
|
||||
// good enough for now as a default.
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("WhereaboutsRequired middleware hit", "country", "US")
|
||||
c.Set("userCountry", "US")
|
||||
region := c.Query("region")
|
||||
slog.Debug("WhereaboutsRequired: middleware hit", "region", region)
|
||||
if region == "" {
|
||||
// If no region is passed, default to server region.
|
||||
if Config.DEFAULT_COUNTRY != "" {
|
||||
slog.Debug("WhereaboutsRequired: Using server default country.", "default_country", Config.DEFAULT_COUNTRY)
|
||||
c.Set("userCountry", Config.DEFAULT_COUNTRY)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
// If no server region set, default to US.
|
||||
slog.Debug("WhereaboutsRequired: Using hard coded default (US).")
|
||||
c.Set("userCountry", "US")
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
c.Set("userCountry", region)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination middleware
|
||||
// Reusable way to get pagination values.
|
||||
// If force=true then will default to page=1, otherwise
|
||||
// assume pagination is disabled when query params not present.
|
||||
func PaginatedRequest(force bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
pageStr := c.Query("p")
|
||||
page := 0
|
||||
if pageStr == "" && force {
|
||||
page = 1
|
||||
slog.Debug("PossiblyPaginated: Pagination is forced, but no page was provided. Using default.")
|
||||
} else if pageStr == "" {
|
||||
slog.Debug("PossiblyPaginated: Pagination is disabled. No parameter provided.")
|
||||
c.Set("paginationEnabled", false)
|
||||
c.Next()
|
||||
return
|
||||
} else {
|
||||
num, err := strconv.Atoi(pageStr)
|
||||
if err != nil {
|
||||
slog.Error("PossiblyPaginated: Query paramater 'p' was not parseable as an int", "err", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'p' must be a number"})
|
||||
return
|
||||
}
|
||||
page = num
|
||||
}
|
||||
limitStr := c.Query("l")
|
||||
limit := 40
|
||||
if limitStr != "" {
|
||||
num, err := strconv.Atoi(limitStr)
|
||||
if err != nil {
|
||||
slog.Error("PossiblyPaginated: Query paramater 'l' was not parseable as an int", "err", err)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query param 'l' must be a number"})
|
||||
return
|
||||
}
|
||||
limit = num
|
||||
} else {
|
||||
slog.Debug("PossiblyPaginated: Using default limit.")
|
||||
}
|
||||
slog.Debug("PossiblyPaginated: middleware hit", "page", page, "page_limit", limit)
|
||||
c.Set("paginationEnabled", true)
|
||||
c.Set("paginationParams", PaginationParams{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
})
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Parameters that the paginator uses to
|
||||
// know what to return.
|
||||
type PaginationParams struct {
|
||||
// Page limit (max amount of items to get for each page).
|
||||
Limit int `json:"limit"`
|
||||
// Page number.
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
// Pagination response struct.
|
||||
type PaginationResponse[T interface{}] struct {
|
||||
PaginationParams
|
||||
// Max amount of pages we can produce from total_results
|
||||
TotalPages int `json:"totalPages"`
|
||||
TotalResults int64 `json:"totalResults"`
|
||||
Results []T `json:"results"`
|
||||
}
|
||||
|
||||
// Call when finished with PaginationResponse, before returning to user.
|
||||
// Performs final calculations.
|
||||
func (r *PaginationResponse[T]) Finished(p PaginationParams) {
|
||||
r.PaginationParams = p
|
||||
if r.TotalResults != 0 && r.Limit != 0 {
|
||||
r.TotalPages = int(math.Ceil(float64(r.TotalResults) / float64(r.Limit)))
|
||||
} else {
|
||||
slog.Warn(
|
||||
"PaginationResponse->Finished: TotalPages not calculated.",
|
||||
"total_results", r.TotalResults,
|
||||
"limit", r.Limit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination gorm scope.
|
||||
// Pass in `PaginationParams` and the `PaginationResponse` will be filled out,
|
||||
// just fill out the `Results` manually.
|
||||
func Paginate[T interface{}](
|
||||
p PaginationParams,
|
||||
r *PaginationResponse[T],
|
||||
) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
offset := (p.Page - 1) * p.Limit
|
||||
return db.Offset(offset).Limit(p.Limit)
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -39,50 +38,6 @@ type PlexHostConfigUpdateResponse struct {
|
||||
PLEX_MACHINE_ID string
|
||||
}
|
||||
|
||||
type PlexUsersResponse struct {
|
||||
XMLName xml.Name `xml:"MediaContainer"`
|
||||
Text string `xml:",chardata"`
|
||||
FriendlyName string `xml:"friendlyName,attr"`
|
||||
Identifier string `xml:"identifier,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
TotalSize string `xml:"totalSize,attr"`
|
||||
Size string `xml:"size,attr"`
|
||||
User []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
Title string `xml:"title,attr"`
|
||||
Username string `xml:"username,attr"`
|
||||
Email string `xml:"email,attr"`
|
||||
RecommendationsPlaylistId string `xml:"recommendationsPlaylistId,attr"`
|
||||
Thumb string `xml:"thumb,attr"`
|
||||
Protected string `xml:"protected,attr"`
|
||||
Home string `xml:"home,attr"`
|
||||
AllowTuners string `xml:"allowTuners,attr"`
|
||||
AllowSync string `xml:"allowSync,attr"`
|
||||
AllowCameraUpload string `xml:"allowCameraUpload,attr"`
|
||||
AllowChannels string `xml:"allowChannels,attr"`
|
||||
AllowSubtitleAdmin string `xml:"allowSubtitleAdmin,attr"`
|
||||
FilterAll string `xml:"filterAll,attr"`
|
||||
FilterMovies string `xml:"filterMovies,attr"`
|
||||
FilterMusic string `xml:"filterMusic,attr"`
|
||||
FilterPhotos string `xml:"filterPhotos,attr"`
|
||||
FilterTelevision string `xml:"filterTelevision,attr"`
|
||||
Restricted string `xml:"restricted,attr"`
|
||||
Server []struct {
|
||||
Text string `xml:",chardata"`
|
||||
ID string `xml:"id,attr"`
|
||||
ServerId string `xml:"serverId,attr"`
|
||||
MachineIdentifier string `xml:"machineIdentifier,attr"`
|
||||
Name string `xml:"name,attr"`
|
||||
LastSeenAt string `xml:"lastSeenAt,attr"`
|
||||
NumLibraries string `xml:"numLibraries,attr"`
|
||||
AllLibraries string `xml:"allLibraries,attr"`
|
||||
Owned string `xml:"owned,attr"`
|
||||
Pending string `xml:"pending,attr"`
|
||||
} `xml:"Server"`
|
||||
} `xml:"User"`
|
||||
}
|
||||
|
||||
// Plex get libraries response
|
||||
// /library/sections
|
||||
type PlexLibrariesResponse struct {
|
||||
@@ -136,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
|
||||
@@ -238,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"`
|
||||
@@ -301,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"`
|
||||
@@ -454,7 +409,6 @@ func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
slog.Info(string(body))
|
||||
defer resp.Body.Close()
|
||||
var pa PlexAccountResponse
|
||||
err = json.Unmarshal(body, &pa)
|
||||
@@ -488,54 +442,6 @@ func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
|
||||
}
|
||||
|
||||
// If a plex user has access to our home plex server (PLEX_HOST).
|
||||
func plexUserHasAccessToPlexHost(token string) error {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/api/users", nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pa PlexUsersResponse
|
||||
err = xml.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(pa.User) <= 0 {
|
||||
return errors.New("found no users in response")
|
||||
}
|
||||
|
||||
// Now check if any of the users servers include our home server machine id
|
||||
homeServerFound := false
|
||||
userLoop:
|
||||
for _, user := range pa.User {
|
||||
for _, server := range user.Server {
|
||||
if server.MachineIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("plexUserHasAccessToPlexHost: Processing a server.", "server", server.MachineIdentifier)
|
||||
homeServerFound = true
|
||||
break userLoop
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
break
|
||||
}
|
||||
}
|
||||
if homeServerFound {
|
||||
return nil
|
||||
}
|
||||
return errors.New("user does not have access to home plex server")
|
||||
}
|
||||
|
||||
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
|
||||
@@ -638,6 +544,7 @@ func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryI
|
||||
|
||||
// Gets users auth token for local plex server,
|
||||
// so they can authenticate against it for api requests.
|
||||
// If no auth token is returned or errored, assume user doesn't have access to home plex server library.
|
||||
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
@@ -666,11 +573,14 @@ func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, e
|
||||
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
|
||||
if v.AccessToken == "" {
|
||||
slog.Warn("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
slog.Error("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
continue
|
||||
}
|
||||
authToken = v.AccessToken
|
||||
}
|
||||
}
|
||||
if authToken == "" {
|
||||
slog.Error("getPlexHomeServerAuthToken: No authToken retrieved!")
|
||||
}
|
||||
return authToken, nil
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||