Compare commits
374 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| b21d2bf603 | |||
| 0667f33250 | |||
| 0da264480c | |||
| 622d10e743 | |||
| 5cbc817089 | |||
| 7c216f4691 | |||
| b5f9700345 | |||
| 7e03557e56 | |||
| 10a79a31cd | |||
| e41d408cd0 | |||
| 6b22b4a4e2 | |||
| 67c3083edd | |||
| e968b7ff28 | |||
| adfd983a68 | |||
| b32ba86c5d | |||
| ebb0b132bd | |||
| 4f1fbf4a45 | |||
| 74f896a8bc | |||
| 3b1d42c148 |
@@ -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,9 @@ on:
|
||||
- "**"
|
||||
tags-ignore:
|
||||
- v*
|
||||
paths:
|
||||
- ".github/workflows/test-pr-web.yml"
|
||||
- "src/**"
|
||||
|
||||
jobs:
|
||||
linter_check:
|
||||
@@ -1,5 +1,5 @@
|
||||
# Backend
|
||||
FROM golang:1.21-alpine AS server
|
||||
FROM golang:1.22-alpine AS server
|
||||
|
||||
WORKDIR /server
|
||||
COPY server/*.go server/go.* ./
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<a href="https://watcharr.app"><img src="https://img.shields.io/website?label=DOCS&style=for-the-badge&url=https%3A%2F%2Fwatcharr.app" /></a>
|
||||
<a href="https://github.com/sbondCo/Watcharr/issues"><img src="https://img.shields.io/github/issues-raw/sbondCo/Watcharr?label=ISSUES&style=for-the-badge" /></a>
|
||||
<a href="/LICENSE"><img src="https://img.shields.io/github/license/sbondCo/Watcharr?style=for-the-badge" /></a>
|
||||
<a href="https://matrix.to/#/#watcharr:matrix.org"><img src="https://img.shields.io/matrix/watcharr%3Amatrix.org?style=for-the-badge&logo=matrix" /></a>
|
||||
</p>
|
||||
|
||||
I'm your new easily self-hosted content watched list. The place you store your watched (or watching, planned, etc) **movies** and **tv shows** (and **anime**), rate them and track their status.
|
||||
@@ -24,6 +25,7 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
- [Screenshots](#screenshots)
|
||||
- [Setup](#set-up)
|
||||
- [Contributing](CONTRIBUTING.md)
|
||||
- [Community Made Tools](#community-made-tools)
|
||||
- [Getting Help](#getting-help)
|
||||
|
||||
# Screenshots
|
||||
@@ -52,6 +54,18 @@ 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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,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 |
@@ -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.
|
||||
@@ -54,7 +54,10 @@ const config = {
|
||||
editUrl: "https://github.com/sbondCo/Watcharr/tree/dev/doc"
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve("./src/css/custom.css")
|
||||
customCss: [
|
||||
"./node_modules/viewerjs/dist/viewer.css",
|
||||
require.resolve("./src/css/custom.css")
|
||||
]
|
||||
}
|
||||
})
|
||||
]
|
||||
@@ -80,6 +83,11 @@ const config = {
|
||||
{
|
||||
type: "docsVersionDropdown"
|
||||
},
|
||||
{
|
||||
href: "https://beta.watcharr.app",
|
||||
label: "Demo",
|
||||
position: "right"
|
||||
},
|
||||
{
|
||||
href: "https://github.com/sbondCo/Watcharr",
|
||||
label: "GitHub",
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
@@ -6338,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",
|
||||
@@ -14119,6 +14120,11 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/viewerjs": {
|
||||
"version": "1.11.6",
|
||||
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.11.6.tgz",
|
||||
"integrity": "sha512-TlhdSp2oEOLFXvEp4psKaeTjR5zBjTRcM/sHUN8PkV1UWuY8HKC8n7GaVdW5Xqnwdr/F1OmzLik1QwDjI4w/nw=="
|
||||
},
|
||||
"node_modules/watchpack": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz",
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"clsx": "^2.1.0",
|
||||
"prism-react-renderer": "^2.3.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
"react-dom": "^18.2.0",
|
||||
"viewerjs": "^1.11.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "3.1.1"
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { useEffect } from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import clsx from "clsx";
|
||||
import Viewer from "viewerjs";
|
||||
|
||||
function TwoColFeat({ title, description, imgSrc }) {
|
||||
return (
|
||||
<div className={clsx(styles.twoCol)}>
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
<div>
|
||||
<img src={imgSrc} alt="" loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HomepageTwoColFeats() {
|
||||
useEffect(() => {
|
||||
const ctr = document.getElementById("homepage-two-col-feats");
|
||||
console.debug("Starting image viewer", ctr);
|
||||
new Viewer(ctr, {
|
||||
navbar: 0,
|
||||
toolbar: {
|
||||
zoomIn: 2,
|
||||
zoomOut: 2,
|
||||
next: 2,
|
||||
prev: 2
|
||||
},
|
||||
title: 0,
|
||||
className: "twoColViewer"
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section id="homepage-two-col-feats" className={styles.twoColFeats}>
|
||||
<TwoColFeat
|
||||
title="Neat UI"
|
||||
description="We just look lightweight."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Easy To Use"
|
||||
description="Not much to it, other than ease of use."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage-poster-change-status.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="In-Depth Details"
|
||||
description="About your favourite content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Personal Stats and Configuration"
|
||||
description="Make it how you like."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/user-profile.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Discovery"
|
||||
description="Find the latest trending content."
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/discover-page.png"
|
||||
/>
|
||||
<TwoColFeat
|
||||
title="Dark Theme"
|
||||
description="For you who are still unsatisfied. ;("
|
||||
imgSrc="https://github.com/sbondCo/Watcharr/raw/dev/screenshot/content-details-page-dark.png"
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
.twoColFeats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
flex-flow: column;
|
||||
width: 100%;
|
||||
max-width: 1300px;
|
||||
}
|
||||
|
||||
.twoCol {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
width: 100%;
|
||||
padding: 2.5rem 5rem;
|
||||
gap: 50px;
|
||||
background-color: rgb(236, 236, 236);
|
||||
}
|
||||
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: row-reverse;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 0px 5px rgb(121, 121, 121);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 850px) {
|
||||
.twoCol,
|
||||
.twoCol:nth-child(even) {
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
.twoCol > div {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.twoCol > div > img {
|
||||
max-width: 500px;
|
||||
}
|
||||
}
|
||||
@@ -28,3 +28,17 @@
|
||||
--ifm-color-primary-lightest: #4fddbf;
|
||||
--docusaurus-highlighted-code-line-bg: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.twoColViewer .viewer-close {
|
||||
right: 10px;
|
||||
top: 10px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
/* Stupid poopin fix for viewerjs because whatever is bundling the css
|
||||
isn't keeping the order of this correctly. All this time wasted and it
|
||||
was just opaque ;( BUT ONLY IN THE BUILD AAAAAAAAAAAAAAAAAAAAAA */
|
||||
.viewer-in {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import LogoCol from "@site/static/img/logo-col.png";
|
||||
|
||||
import styles from "./index.module.css";
|
||||
import clsx from "clsx";
|
||||
import { HomepageTwoColFeats } from "../components/TwoColFeat";
|
||||
|
||||
function HomepageHeader() {
|
||||
const { siteConfig } = useDocusaurusContext();
|
||||
@@ -45,6 +46,7 @@ export default function Home() {
|
||||
<HomepageHeader />
|
||||
<main>
|
||||
<HomepageFeatures />
|
||||
<HomepageTwoColFeats />
|
||||
</main>
|
||||
</Layout>
|
||||
);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "1.35.2",
|
||||
"version": "1.44.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -13,28 +13,28 @@
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^4.0.1",
|
||||
"@sveltejs/kit": "^2.0.8",
|
||||
"@sveltejs/adapter-node": "^5.2.3",
|
||||
"@sveltejs/kit": "^2.5.27",
|
||||
"@types/papaparse": "^5.3.14",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"@typescript-eslint/parser": "^7.0.2",
|
||||
"@vite-pwa/sveltekit": "^0.3.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.14.1",
|
||||
"@typescript-eslint/parser": "^7.8.0",
|
||||
"@vite-pwa/sveltekit": "^0.5.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.3"
|
||||
"eslint-plugin-svelte": "^2.43.0",
|
||||
"prettier": "^3.3.2",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-check": "^4.0.2",
|
||||
"svelte-eslint-parser": "^0.41.0",
|
||||
"svelte-preprocess": "^6.0.2",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.6"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"axios": "^1.7.4",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
}
|
||||
|
||||
@@ -10,28 +10,36 @@ 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"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
|
||||
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
|
||||
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
|
||||
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
|
||||
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
|
||||
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
|
||||
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
|
||||
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
|
||||
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
|
||||
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_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"
|
||||
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
|
||||
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
|
||||
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
|
||||
@@ -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,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,6 +28,7 @@ type UserType uint8
|
||||
var (
|
||||
// Assume watcharr user if none of these...
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
)
|
||||
|
||||
// User Perms
|
||||
@@ -38,6 +39,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.
|
||||
@@ -56,8 +58,13 @@ type User struct {
|
||||
ThirdPartyID string `json:"-"`
|
||||
// Auth token from third party (jellyfin)
|
||||
ThirdPartyAuth string `json:"-"`
|
||||
Watched []Watched
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
// All Tags
|
||||
Tags []Tag `json:"-"`
|
||||
// Users permissions
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
@@ -84,6 +91,39 @@ 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.
|
||||
// Each service may use the fields in their own way.
|
||||
// Unique index applied between service name and clientID
|
||||
// to ensure no duplicates (no need to apply it against
|
||||
// user_id, no accounts should share an integration).
|
||||
//
|
||||
// Plex:
|
||||
// - AuthToken : Used for requests against plex.tv
|
||||
// - AuthToken2 : Used for requests against home plex server.
|
||||
type UserServices struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Service/integration name
|
||||
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
// The users id on the third party service
|
||||
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
AuthToken string `gorm:"not null;" json:"-"`
|
||||
// Second auth token, generic name so future services can use it without extra confusion.
|
||||
// Ex: We require a second auth token for use with our local server for Plex.
|
||||
AuthToken2 string `json:"-"`
|
||||
UserID uint `gorm:"not null;" json:"-"`
|
||||
}
|
||||
|
||||
// We use a separate struct for registration to avoid confusion
|
||||
@@ -119,6 +159,7 @@ type AvailableAuthProvidersResponse struct {
|
||||
AvailableAuthProviders []string `json:"available"`
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
IsInSetup bool `json:"isInSetup"`
|
||||
UseEmby bool `json:"useEmby"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
@@ -165,7 +206,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)
|
||||
@@ -226,6 +267,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.")
|
||||
@@ -247,6 +303,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
|
||||
@@ -369,7 +427,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
|
||||
dbUser := new(User)
|
||||
dbRes := db.Where("third_party_id = ?", resp.User.ID).Take(&dbUser)
|
||||
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, JELLYFIN_USER).Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
// Record not found, so we should create the user
|
||||
@@ -378,6 +436,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 {
|
||||
@@ -403,6 +462,75 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
// Login via Plex.
|
||||
func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("Request made to login via Plex, but Plex authentication is disabled")
|
||||
return AuthResponse{}, errors.New("plex login not enabled")
|
||||
}
|
||||
slog.Debug("A Plex User Is Logging In")
|
||||
account, err := fetchPlexAccountFromToken(lr.AuthToken)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Could not fetch Plex account", "error", err)
|
||||
return AuthResponse{}, errors.New("could not fetch plex acount")
|
||||
}
|
||||
if account.Username == "" || account.Id == 0 {
|
||||
slog.Error("loginPlex: Username or id missing from account response:", "username", account.Username, "id", account.Id)
|
||||
return AuthResponse{}, errors.New("data is missing from the plex account response")
|
||||
}
|
||||
// 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.")
|
||||
dbUser.Username = account.Username
|
||||
dbUser.Type = PLEX_USER
|
||||
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)
|
||||
return AuthResponse{}, errors.New("failed to create new user from plex")
|
||||
}
|
||||
} else {
|
||||
slog.Error("loginPlex: Failed to select user from database for login", "error", dbRes.Error)
|
||||
return AuthResponse{}, errors.New("failed to locate user")
|
||||
}
|
||||
} else {
|
||||
// If user exists.. update their access tokens in db
|
||||
for i, v := range dbUser.UserServices {
|
||||
if v.Name == "plex" {
|
||||
slog.Info("loginPlex: Found plex user service.. attemping to update")
|
||||
dbUser.UserServices[i].AuthToken = lr.AuthToken
|
||||
if homeAuthToken != "" {
|
||||
dbUser.UserServices[i].AuthToken2 = homeAuthToken
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
db.Save(&dbUser.UserServices)
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
}
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
var dbToken Token
|
||||
resp := db.Where("value = ?", req.Token).Take(&dbToken)
|
||||
@@ -458,7 +586,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) {
|
||||
|
||||
@@ -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
|
||||
@@ -31,10 +45,22 @@ type ServerConfig struct {
|
||||
// If unprovided, the default Watcharr API key will be used.
|
||||
TMDB_KEY string `json:",omitempty"`
|
||||
|
||||
// Optional: Point to Plex install to enable plex features.
|
||||
PLEX_HOST string `json:",omitempty"`
|
||||
|
||||
// Optional: Machine identifier of your Plex server.
|
||||
// This is used to ensure only users of your Plex server
|
||||
// can use this Watcharr instance.
|
||||
// Will be fetched automatically when PLEX_HOST is provided via web ui.
|
||||
PLEX_MACHINE_ID string `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
|
||||
// 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.
|
||||
@@ -50,12 +76,16 @@ type ServerConfig struct {
|
||||
// not editable on frontend, so not needed).
|
||||
func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
return ServerConfig{
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
JELLYFIN_HOST: c.JELLYFIN_HOST,
|
||||
TMDB_KEY: c.TMDB_KEY,
|
||||
DEBUG: c.DEBUG,
|
||||
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
|
||||
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
|
||||
SIGNUP_ENABLED: c.SIGNUP_ENABLED,
|
||||
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,
|
||||
DEBUG: c.DEBUG,
|
||||
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
|
||||
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
|
||||
TWITCH: game.IGDB{
|
||||
ClientID: c.TWITCH.ClientID,
|
||||
ClientSecret: c.TWITCH.ClientSecret,
|
||||
@@ -71,7 +101,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.")
|
||||
@@ -108,14 +138,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
|
||||
@@ -126,6 +157,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" {
|
||||
@@ -133,6 +166,8 @@ 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")
|
||||
}
|
||||
@@ -145,7 +180,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 {
|
||||
@@ -162,16 +197,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/gin-contrib/cache/persistence"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
@@ -17,6 +20,8 @@ const (
|
||||
SHOW ContentType = "tv"
|
||||
)
|
||||
|
||||
var ContentStore = persistence.NewInMemoryStore(time.Hour * 24)
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
@@ -85,7 +90,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 +175,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,9 +243,12 @@ func transformProviders(c *interface{}, country string) {
|
||||
}
|
||||
}
|
||||
|
||||
func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBSearchMultiResponse)
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": "1"}, &resp)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
@@ -204,6 +256,54 @@ func searchContent(query string) (TMDBSearchMultiResponse, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
resp := new(TMDBSearchMoviesResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie search request!", "error", err.Error())
|
||||
return TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
resp := new(TMDBSearchShowsResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv search request!", "error", err.Error())
|
||||
return TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
|
||||
resp := new(TMDBSearchPeopleResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/person", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete people search request!", "error", err.Error())
|
||||
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "person"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
@@ -248,13 +348,26 @@ func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
var cacheKey = "contentstore-seasondetails-" + tvId + "-" + seasonNumber
|
||||
resp := new(TMDBSeasonDetails)
|
||||
if err := ContentStore.Get(cacheKey, &resp); err != nil {
|
||||
if err != persistence.ErrCacheMiss {
|
||||
slog.Error("seasonDetails: Cache failed for some reason", "error", err)
|
||||
}
|
||||
} else {
|
||||
slog.Debug("seasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete season details request!", "error", err.Error())
|
||||
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
if err := ContentStore.Set(cacheKey, resp, time.Hour*24); err != nil {
|
||||
slog.Error("seasonDetails: Failed to set cache!", "error", err)
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -331,3 +444,13 @@ func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.5.0
|
||||
github.com/gin-gonic/gin v1.9.1
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0
|
||||
github.com/gin-contrib/cache v1.3.0
|
||||
github.com/gin-contrib/cors v1.7.2
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.11.0
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.19.0
|
||||
golang.org/x/crypto v0.27.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.6
|
||||
gorm.io/gorm v1.25.12
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 // indirect
|
||||
github.com/bytedance/sonic v1.10.2 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.0 // indirect
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.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.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.15.5 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.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/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.5 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // 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.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.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // 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.11 // indirect
|
||||
golang.org/x/arch v0.5.0 // indirect
|
||||
golang.org/x/net v0.17.0 // indirect
|
||||
golang.org/x/sys v0.17.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sys v0.25.0 // indirect
|
||||
golang.org/x/text v0.18.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -2,71 +2,72 @@ 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.10.2 h1:GQebETVBxYB7JGWJtLBi07OVzWwt+8dWA00gEVW2ZFE=
|
||||
github.com/bytedance/sonic v1.10.2/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.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.5.0 h1:DgGKV7DDoOn36DFkNtbHrjoRiT5ExCe+PC9/xp7aKvk=
|
||||
github.com/gin-contrib/cors v1.5.0/go.mod h1:TvU7MAZ3EwrPLI2ztzTt3tqgvBCq+wn8WpZmfADjupI=
|
||||
github.com/gin-contrib/cache v1.3.0 h1:wEEw38uvb4rTraQJVpd9ex4ZotXNlc0fSaSUsuPXS/w=
|
||||
github.com/gin-contrib/cache v1.3.0/go.mod h1:EA63LrWGI5vwSI95TS5fgBrtxZ1tM2NKx+NrEeyEDcU=
|
||||
github.com/gin-contrib/cors v1.7.2 h1:oLDHxdg8W/XDoN/8zamqk/Drgt4oVZDvaV0YmvVICQw=
|
||||
github.com/gin-contrib/cors v1.7.2/go.mod h1:SUJVARKgQ40dmrzgXEVxj2m7Ig1v1qIboQkPDTQ9t2E=
|
||||
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.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||
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.15.5 h1:LEBecTWb/1j5TNY1YYG2RcOUN3R7NLylN+x8TTueE24=
|
||||
github.com/go-playground/validator/v10 v10.15.5/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws=
|
||||
github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/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.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
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.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=
|
||||
@@ -74,10 +75,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.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
|
||||
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
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=
|
||||
@@ -85,35 +88,38 @@ github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6po
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
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.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.5.0 h1:jpGode6huXQxcskEIpOCvrU+tzo81b6+oFLUYXWtH/Y=
|
||||
golang.org/x/arch v0.5.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
|
||||
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||
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.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -122,9 +128,9 @@ gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYs
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/sqlite v1.5.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.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||
gorm.io/driver/sqlite v1.5.6/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,18 @@ 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"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
@@ -67,7 +71,8 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
return successfulImport(db, userId, cr.ID, SHOW, ar)
|
||||
}
|
||||
}
|
||||
sr, err := searchContent(ar.Name)
|
||||
// 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 +175,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
|
||||
}
|
||||
@@ -207,7 +207,7 @@ func startJellyfinSync(
|
||||
&seriesSeasons,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesSeasons.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
@@ -227,7 +227,7 @@ func startJellyfinSync(
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
}
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func startJellyfinSync(
|
||||
&seriesEpisodes,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesEpisodes.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
@@ -270,7 +270,7 @@ func startJellyfinSync(
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId)
|
||||
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -8,13 +8,24 @@ import (
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,586 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PlexLoginRequest struct {
|
||||
AuthToken string `json:"token" binding:"required"`
|
||||
ClientIdentifier string `json:"clientIdentifier" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexUser struct {
|
||||
Id uint64 `json:"id" binding:"required"`
|
||||
Uuid string `json:"uuid" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexAccountResponse struct {
|
||||
User PlexUser `json:"user" binding:"required"`
|
||||
}
|
||||
|
||||
type PlexIdentity struct {
|
||||
MediaContainer struct {
|
||||
MachineIdentifier string `json:"machineIdentifier"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
type PlexHostConfigUpdateResponse struct {
|
||||
PLEX_MACHINE_ID string
|
||||
}
|
||||
|
||||
// Plex get libraries response
|
||||
// /library/sections
|
||||
type PlexLibrariesResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Title1 string `json:"title1"`
|
||||
Directory []struct {
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Composite string `json:"composite"`
|
||||
Filters bool `json:"filters"`
|
||||
Refreshing bool `json:"refreshing"`
|
||||
Thumb string `json:"thumb"`
|
||||
Key string `json:"key"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Agent string `json:"agent"`
|
||||
Scanner string `json:"scanner"`
|
||||
Language string `json:"language"`
|
||||
UUID string `json:"uuid"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
CreatedAt int `json:"createdAt"`
|
||||
ScannedAt int `json:"scannedAt"`
|
||||
Content bool `json:"content"`
|
||||
Directory bool `json:"directory"`
|
||||
ContentChangedAt int64 `json:"contentChangedAt"`
|
||||
Hidden int `json:"hidden"`
|
||||
Location []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"Location"`
|
||||
} `json:"Directory"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all library items response (with includeGuids parameter)
|
||||
// /library/sections/{sectionId}/all
|
||||
type PlexLibraryItemsResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
Thumb string `json:"thumb"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
// ViewMode int `json:"viewMode"` // 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
|
||||
Slug string `json:"slug"`
|
||||
Studio string `json:"studio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ContentRating string `json:"contentRating"`
|
||||
Summary string `json:"summary"`
|
||||
Rating float64 `json:"rating"`
|
||||
UserRating float64 `json:"userRating"`
|
||||
AudienceRating float64 `json:"audienceRating"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
Year int `json:"year"`
|
||||
Tagline string `json:"tagline"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
Duration int `json:"duration"`
|
||||
OriginallyAvailableAt string `json:"originallyAvailableAt"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
AudienceRatingImage string `json:"audienceRatingImage"`
|
||||
HasPremiumPrimaryExtra string `json:"hasPremiumPrimaryExtra"`
|
||||
RatingImage string `json:"ratingImage"`
|
||||
LeafCount int `json:"leafCount,omitempty"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount,omitempty"`
|
||||
Media []struct {
|
||||
ID int `json:"id"`
|
||||
Duration int `json:"duration"`
|
||||
Bitrate int `json:"bitrate"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
AspectRatio float64 `json:"aspectRatio"`
|
||||
AudioChannels int `json:"audioChannels"`
|
||||
AudioCodec string `json:"audioCodec"`
|
||||
VideoCodec string `json:"videoCodec"`
|
||||
VideoResolution string `json:"videoResolution"`
|
||||
Container string `json:"container"`
|
||||
VideoFrameRate string `json:"videoFrameRate"`
|
||||
VideoProfile string `json:"videoProfile"`
|
||||
Part []struct {
|
||||
ID int `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Duration int `json:"duration"`
|
||||
File string `json:"file"`
|
||||
Size int64 `json:"size"`
|
||||
Container string `json:"container"`
|
||||
HasThumbnail string `json:"hasThumbnail"`
|
||||
VideoProfile string `json:"videoProfile"`
|
||||
} `json:"Part"`
|
||||
} `json:"Media"`
|
||||
// External ids
|
||||
Guid []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"Guid"`
|
||||
Genre []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Genre"`
|
||||
Country []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Country"`
|
||||
Director []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Director"`
|
||||
Writer []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Writer"`
|
||||
Role []struct {
|
||||
Tag string `json:"tag"`
|
||||
} `json:"Role"`
|
||||
ChapterSource string `json:"chapterSource,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all children (seasons) for a serie.
|
||||
// /library/metadata/{ratingKey}/children
|
||||
type PlexLibraryItemSeasonsResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
Key string `json:"key"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
Nocache bool `json:"nocache"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Summary string `json:"summary"`
|
||||
Theme string `json:"theme"`
|
||||
Thumb string `json:"thumb"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
|
||||
Directory []struct {
|
||||
LeafCount int `json:"leafCount"`
|
||||
Thumb string `json:"thumb"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
Key string `json:"key"`
|
||||
Title string `json:"title"`
|
||||
} `json:"Directory"`
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
GUID string `json:"guid"`
|
||||
ParentGUID string `json:"parentGuid"`
|
||||
ParentSlug string `json:"parentSlug"`
|
||||
ParentStudio string `json:"parentStudio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
ParentKey string `json:"parentKey"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
Summary string `json:"summary"`
|
||||
Index int `json:"index"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
UserRating float64 `json:"userRating,omitempty"`
|
||||
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
ParentThumb string `json:"parentThumb"`
|
||||
ParentTheme string `json:"parentTheme"`
|
||||
LeafCount int `json:"leafCount"`
|
||||
ViewedLeafCount int `json:"viewedLeafCount"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Plex get all children (episodes) for a season.
|
||||
// /library/metadata/{ratingKey}/allLeaves
|
||||
type PlexLibraryItemEpisodesResponse struct {
|
||||
MediaContainer struct {
|
||||
Size int `json:"size"`
|
||||
AllowSync bool `json:"allowSync"`
|
||||
Art string `json:"art"`
|
||||
Identifier string `json:"identifier"`
|
||||
Key string `json:"key"`
|
||||
LibrarySectionID int `json:"librarySectionID"`
|
||||
LibrarySectionTitle string `json:"librarySectionTitle"`
|
||||
LibrarySectionUUID string `json:"librarySectionUUID"`
|
||||
MediaTagPrefix string `json:"mediaTagPrefix"`
|
||||
MediaTagVersion int `json:"mediaTagVersion"`
|
||||
MixedParents bool `json:"mixedParents"`
|
||||
Nocache bool `json:"nocache"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ParentYear int `json:"parentYear"`
|
||||
Theme string `json:"theme"`
|
||||
Title1 string `json:"title1"`
|
||||
Title2 string `json:"title2"`
|
||||
ViewGroup string `json:"viewGroup"`
|
||||
// ViewMode int `json:"viewMode"` // Causing string error, not used so commented out for now.
|
||||
Metadata []struct {
|
||||
RatingKey string `json:"ratingKey"`
|
||||
Key string `json:"key"`
|
||||
ParentRatingKey string `json:"parentRatingKey"`
|
||||
GrandparentRatingKey string `json:"grandparentRatingKey"`
|
||||
GUID string `json:"guid"`
|
||||
GrandparentSlug string `json:"grandparentSlug"`
|
||||
Studio string `json:"studio"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
GrandparentKey string `json:"grandparentKey"`
|
||||
ParentKey string `json:"parentKey"`
|
||||
GrandparentTitle string `json:"grandparentTitle"`
|
||||
ParentTitle string `json:"parentTitle"`
|
||||
ContentRating string `json:"contentRating"`
|
||||
Summary string `json:"summary"`
|
||||
Index int `json:"index"`
|
||||
ParentIndex int `json:"parentIndex"`
|
||||
AudienceRating float64 `json:"audienceRating"`
|
||||
Year int `json:"year"`
|
||||
Thumb string `json:"thumb"`
|
||||
Art string `json:"art"`
|
||||
ParentThumb string `json:"parentThumb"`
|
||||
GrandparentThumb string `json:"grandparentThumb"`
|
||||
GrandparentArt string `json:"grandparentArt"`
|
||||
GrandparentTheme string `json:"grandparentTheme"`
|
||||
Duration int `json:"duration"`
|
||||
OriginallyAvailableAt string `json:"originallyAvailableAt"`
|
||||
AddedAt int `json:"addedAt"`
|
||||
UpdatedAt int `json:"updatedAt"`
|
||||
AudienceRatingImage string `json:"audienceRatingImage"`
|
||||
ChapterSource string `json:"chapterSource"`
|
||||
TitleSort string `json:"titleSort,omitempty"`
|
||||
UserRating float64 `json:"userRating,omitempty"`
|
||||
ViewCount int `json:"viewCount,omitempty"`
|
||||
LastViewedAt int64 `json:"lastViewedAt,omitempty"`
|
||||
LastRatedAt int64 `json:"lastRatedAt,omitempty"`
|
||||
} `json:"Metadata"`
|
||||
} `json:"MediaContainer"`
|
||||
}
|
||||
|
||||
// Response from clients.plex.tv/api/v2/resources
|
||||
// Used to get users auth token for home plex server.
|
||||
type PlexClientResources []struct {
|
||||
Name string `json:"name"`
|
||||
Product string `json:"product"`
|
||||
ProductVersion string `json:"productVersion"`
|
||||
Platform string `json:"platform"`
|
||||
PlatformVersion string `json:"platformVersion"`
|
||||
Device string `json:"device"`
|
||||
ClientIdentifier string `json:"clientIdentifier"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
LastSeenAt time.Time `json:"lastSeenAt"`
|
||||
Provides string `json:"provides"`
|
||||
OwnerID interface{} `json:"ownerId"`
|
||||
SourceTitle interface{} `json:"sourceTitle"`
|
||||
PublicAddress string `json:"publicAddress"`
|
||||
AccessToken string `json:"accessToken"`
|
||||
Owned bool `json:"owned"`
|
||||
Home bool `json:"home"`
|
||||
Synced bool `json:"synced"`
|
||||
Relay bool `json:"relay"`
|
||||
Presence bool `json:"presence"`
|
||||
HTTPSRequired bool `json:"httpsRequired"`
|
||||
PublicAddressMatches bool `json:"publicAddressMatches"`
|
||||
DNSRebindingProtection bool `json:"dnsRebindingProtection"`
|
||||
NatLoopbackSupported bool `json:"natLoopbackSupported"`
|
||||
Connections []struct {
|
||||
Protocol string `json:"protocol"`
|
||||
Address string `json:"address"`
|
||||
Port int `json:"port"`
|
||||
URI string `json:"uri"`
|
||||
Local bool `json:"local"`
|
||||
Relay bool `json:"relay"`
|
||||
IPv6 bool `json:"IPv6"`
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// Plex access middleware, ensures user is a Plex user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func PlexAccessRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != PLEX_USER {
|
||||
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
userPlexService := new(UserServices)
|
||||
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
|
||||
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
|
||||
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
c.Set("plexAuthToken", userPlexService.AuthToken)
|
||||
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
|
||||
}
|
||||
}
|
||||
|
||||
func getPlexIdentity(host string) (PlexIdentity, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", host+"/identity", nil)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pi PlexIdentity
|
||||
err = json.Unmarshal(body, &pi)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
}
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
req.Header.Set("X-Plex-Token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pa PlexAccountResponse
|
||||
err = json.Unmarshal(body, &pa)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
}
|
||||
return pa.User, nil
|
||||
}
|
||||
|
||||
// Update plex host setting
|
||||
func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
|
||||
Config.PLEX_HOST = v
|
||||
if Config.PLEX_HOST != "" {
|
||||
pi, err := getPlexIdentity(Config.PLEX_HOST)
|
||||
if err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to get plex server identity!", "error", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to get Plex server identity. Please try setting the Plex Host again or setting PLEX_MACHINE_ID manually in your config file")
|
||||
}
|
||||
if pi.MediaContainer.MachineIdentifier == "" {
|
||||
slog.Error("updateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("got Plex server identity, but no machine id was found")
|
||||
}
|
||||
Config.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
|
||||
} else {
|
||||
Config.PLEX_MACHINE_ID = ""
|
||||
}
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to write updated config to file!", "err", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to write config")
|
||||
}
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
|
||||
}
|
||||
|
||||
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibrariesResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemsResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemSeasonsResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexLibraryItemEpisodesResponse
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
// Gets users auth token for local plex server,
|
||||
// so they can authenticate against it for api requests.
|
||||
// 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)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Add("Accept", "application/json")
|
||||
req.Header.Set("X-Plex-Token", plexAuth)
|
||||
req.Header.Set("X-Plex-Client-Identifier", userClientId)
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var pl PlexClientResources
|
||||
err = json.Unmarshal(body, &pl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
authToken := ""
|
||||
for _, v := range pl {
|
||||
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
|
||||
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
|
||||
if v.AccessToken == "" {
|
||||
slog.Error("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
continue
|
||||
}
|
||||
authToken = v.AccessToken
|
||||
}
|
||||
}
|
||||
if authToken == "" {
|
||||
slog.Error("getPlexHomeServerAuthToken: No authToken retrieved!")
|
||||
}
|
||||
return authToken, nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type PlexSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
// Perform a Plex sync.
|
||||
// Errors are added silently to the job.
|
||||
func startPlexSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) {
|
||||
updateJobCurrentTask(jobId, userId, "fetching libraries")
|
||||
libraries, err := getPlexLibraries(userPlexLocalAuth)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to get plex libraries")
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
return
|
||||
}
|
||||
for _, library := range libraries.MediaContainer.Directory {
|
||||
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
|
||||
if library.Type == "movie" {
|
||||
updateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
|
||||
movies, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, movie := range movies.MediaContainer.Metadata {
|
||||
if movie.ViewCount == 0 {
|
||||
// Not viewed and not rated, skip importing
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
|
||||
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
|
||||
|
||||
// Find tmdb id
|
||||
if len(movie.Guid) <= 0 {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbIdStr := ""
|
||||
for _, v := range movie.Guid {
|
||||
if strings.HasPrefix(v.ID, "tmdb://") {
|
||||
tmdbIdStr = v.ID[7:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
Rating: float64(movie.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Error("plexSyncWatched: unique constraint hit. movie must already be on watch list", "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add movie "+movie.Title)
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
|
||||
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if library.Type == "show" {
|
||||
updateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
|
||||
shows, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, show := range shows.MediaContainer.Metadata {
|
||||
if show.ViewedLeafCount != show.LeafCount {
|
||||
// Not viewed, skip importing
|
||||
// (could be improved to set status as watching when viewedLeafCount is higher than 0)
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing show "+show.Title)
|
||||
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
|
||||
|
||||
tmdbIdStr := ""
|
||||
for _, v := range show.Guid {
|
||||
if strings.HasPrefix(v.ID, "tmdb://") {
|
||||
tmdbIdStr = v.ID[7:]
|
||||
break
|
||||
}
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(show.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
Rating: float64(show.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
if err != nil {
|
||||
if err.Error() == "content already on watched list" {
|
||||
slog.Info("plexSyncWatched: unique constraint hit. show must already be on watch list", "error", err)
|
||||
} else {
|
||||
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add show "+show.Title)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
|
||||
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched seasons for this serie
|
||||
seriesSeasons, err := getPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesSeasons.MediaContainer.Metadata {
|
||||
slog.Debug("plexSyncWatched: Processing a season.", "full_item", vs, "user_id", userId)
|
||||
if vs.ViewedLeafCount != vs.LeafCount {
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
var seasonLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_PLEX,
|
||||
addActivityDate: seasonLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched episodes for this serie
|
||||
seriesEpisodes, err := getPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
for _, vs := range seriesEpisodes.MediaContainer.Metadata {
|
||||
slog.Debug("plexSyncWatched: Processing an episode.", "full_item", vs, "user_id", userId)
|
||||
if vs.ViewCount <= 0 {
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
|
||||
var episodeLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndex,
|
||||
EpisodeNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_PLEX,
|
||||
addActivityDate: episodeLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
}
|
||||
|
||||
func plexSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) (PlexSyncResponse, error) {
|
||||
jobId, err := addJob("plex_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("startPlexSync: Failed to create a job", "error", err)
|
||||
return PlexSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
|
||||
go startPlexSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
userPlexLocalAuth,
|
||||
)
|
||||
|
||||
return PlexSyncResponse{JobId: jobId}, nil
|
||||
}
|
||||
@@ -18,6 +18,10 @@ type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ValueRequest struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type KeyValueRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
@@ -75,13 +79,94 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
exp := time.Hour * 24
|
||||
|
||||
// Search for content
|
||||
content.GET("/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// println(c.Param("query"))
|
||||
content.GET("/search/multi/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
c.Status(400)
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := searchContent(c.Param("query"))
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchContent(c.Param("query"), pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for movies
|
||||
content.GET("/search/movie/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchMovies(c.Param("query"), pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for shows
|
||||
content.GET("/search/tv/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchTv(c.Param("query"), pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Search for people
|
||||
content.GET("/search/person/:query", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("query") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pageQ := c.Query("page")
|
||||
pageNum := 1
|
||||
if pageQ != "" {
|
||||
num, err := strconv.Atoi(pageQ)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "query parameter 'page' is not a number"})
|
||||
return
|
||||
}
|
||||
pageNum = num
|
||||
}
|
||||
content, err := searchPeople(c.Param("query"), pageNum)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -90,7 +175,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get movie details (for movie page)
|
||||
content.Use(WhereaboutsRequired()).GET("/movie/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/movie/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -118,7 +203,7 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get tv details (for tv page)
|
||||
content.Use(WhereaboutsRequired()).GET("/tv/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
content.GET("/tv/:id", WhereaboutsRequired(), cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -146,7 +231,8 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}))
|
||||
|
||||
// Get season details
|
||||
content.GET("/tv/:id/season/:num", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
// Supports `watchedId` query parameter for saving the requested season as `LastViewedSeason`.
|
||||
content.GET("/tv/:id/season/:num", func(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
@@ -156,8 +242,30 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// If a `watchedId` is passed, we should update it with this season
|
||||
// number, so the LastViewedSeason field is up to date (this seemed
|
||||
// better than making a new request for just saving this).
|
||||
// We will attach a `watcharr-lastviewedseason-saved` header if
|
||||
// this part succeeds so the client can decide on showing an error.
|
||||
if watchedIdQ := c.Query("watchedId"); watchedIdQ != "" {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
watchedId, err := strconv.ParseUint(watchedIdQ, 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("get season details route: Processing watchedId param failed", "error", err.Error(), "id", watchedIdQ)
|
||||
} else {
|
||||
if seasonNum, err := strconv.ParseInt(c.Param("num"), 10, 64); err == nil {
|
||||
if err = updateWatchedLastViewedSeason(b.db, userId, uint(watchedId), int(seasonNum)); err == nil {
|
||||
c.Header("watcharr-lastviewedseason-saved", "1")
|
||||
}
|
||||
} else {
|
||||
slog.Error("get season details route: Parsing season number as int failed", "error", err.Error(), "season_num", c.Param("num"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Debug("get season details route: No watchedId parameter found.. not doing anything.")
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
})
|
||||
|
||||
// Get person details
|
||||
content.GET("/person/:id", cache.CachePage(b.ms, exp, func(c *gin.Context) {
|
||||
@@ -236,6 +344,16 @@ func (b *BaseRouter) addContentRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}))
|
||||
|
||||
// Available regions for watch providers
|
||||
content.GET("/regions", func(c *gin.Context) {
|
||||
r, err := regions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addGameRoutes() {
|
||||
@@ -384,12 +502,8 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
|
||||
watched.DELETE(":id", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
if err == nil {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := removeWatched(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
@@ -462,6 +576,50 @@ func (b *BaseRouter) addWatchedRoutes() {
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
watched.POST(":id/tag/:tagId", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = addWatchedToTag(b.db, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
watched.DELETE(":id/tag/:tagId", func(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = rmWatchedFromTag(b.db, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addActivityRoutes() {
|
||||
@@ -569,6 +727,21 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Plex login
|
||||
auth.POST("/plex", func(c *gin.Context) {
|
||||
var plexRequest PlexLoginRequest
|
||||
if c.ShouldBindJSON(&plexRequest) == nil {
|
||||
response, err := loginPlex(&plexRequest, b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
})
|
||||
|
||||
// Register
|
||||
auth.POST("/register", func(c *gin.Context) {
|
||||
var user UserRegisterRequest
|
||||
@@ -590,10 +763,14 @@ func (b *BaseRouter) addAuthRoutes() {
|
||||
if Config.JELLYFIN_HOST != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "jellyfin")
|
||||
}
|
||||
if Config.PLEX_HOST != "" && Config.PLEX_MACHINE_ID != "" {
|
||||
availableAuthProviders = append(availableAuthProviders, "plex")
|
||||
}
|
||||
c.JSON(http.StatusOK, &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: availableAuthProviders,
|
||||
SignupEnabled: Config.SIGNUP_ENABLED,
|
||||
IsInSetup: ServerInSetup,
|
||||
UseEmby: Config.USE_EMBY,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -696,6 +873,22 @@ func (b *BaseRouter) addJellyfinRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addPlexRoutes() {
|
||||
plex := b.rg.Group("/plex").Use(AuthRequired(b.db), PlexAccessRequired(b.db))
|
||||
|
||||
// Sync users plex watched items to watchlist
|
||||
plex.GET("/sync", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userPlexLocalAuth := c.MustGet("plexLocalAuthToken").(string)
|
||||
response, err := plexSyncWatched(b.db, userId, userPlexLocalAuth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addUserRoutes() {
|
||||
u := b.rg.Group("/user").Use(AuthRequired(b.db))
|
||||
|
||||
@@ -876,6 +1069,22 @@ func (b *BaseRouter) addImportRoutes() {
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
imprt.POST("/trakt", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar TraktImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := traktImportWatched(b.db, userId, ar.Username)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addServerRoutes() {
|
||||
@@ -903,10 +1112,58 @@ func (b *BaseRouter) addServerRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Update plex host config
|
||||
server.POST("/config/plex_host", func(c *gin.Context) {
|
||||
var ur ValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := updateConfigPlexHost(ur.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Get server stats
|
||||
server.GET("/stats", cache.CachePage(b.ms, time.Minute*5, func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getServerStats(b.db))
|
||||
}))
|
||||
|
||||
// Get all server users (for manage users page)
|
||||
server.GET("/users", func(c *gin.Context) {
|
||||
resp, err := getAllUsers(b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
// Edit a user (for manage users page)
|
||||
server.POST("/users/:id", func(c *gin.Context) {
|
||||
userId, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/users/:id failed to parse id as a uint", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: "failed to parse id"})
|
||||
return
|
||||
}
|
||||
var ur UpdateUserRequest
|
||||
err = c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := manageUser(b.db, uint(userId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addFeatureRoutes() {
|
||||
@@ -919,10 +1176,12 @@ func (b *BaseRouter) addFeatureRoutes() {
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addSonarrRoutes() {
|
||||
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db), AdminRequired())
|
||||
s := b.rg.Group("/arr/son").Use(AuthRequired(b.db))
|
||||
|
||||
// Routes are manually given AdminRequired or PermRequired middleware.
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", func(c *gin.Context) {
|
||||
s.POST("/test", AdminRequired(), func(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -938,7 +1197,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Used to get config for specific server (quality profile, root folder, etc)
|
||||
s.GET("/config/:name", func(c *gin.Context) {
|
||||
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
server, err := getSonarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -953,7 +1212,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Add sonarr server into config
|
||||
s.POST("/add", func(c *gin.Context) {
|
||||
s.POST("/add", AdminRequired(), func(c *gin.Context) {
|
||||
var ur SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -969,7 +1228,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Edit sonarr servers config
|
||||
s.POST("/edit", func(c *gin.Context) {
|
||||
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
|
||||
var ur SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -985,7 +1244,7 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Remove sonarr server
|
||||
s.POST("/rm/:name", func(c *gin.Context) {
|
||||
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
|
||||
err := rmSonarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -995,40 +1254,105 @@ func (b *BaseRouter) addSonarrRoutes() {
|
||||
})
|
||||
|
||||
// Get safe config for all sonarr servers
|
||||
s.GET("", func(c *gin.Context) {
|
||||
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response := getSonarrsSafe()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Request a show
|
||||
s.POST("/request", func(c *gin.Context) {
|
||||
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
perms := c.GetInt("userPermissions")
|
||||
response, err := createSonarrRequest(b.db, userId, perms, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
err = sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/request/:tmdbId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getArrRequestByTmdbId(b.db, SHOW, tmdbId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request/approve/:id", PermRequired(PERM_ADMIN), func(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := approveSonarrRequest(b.db, uint(requestId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/status/:serverName/:arrId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response, err := getSonarrQueueDetails(c.Param("serverName"), c.Param("arrId"))
|
||||
if err != nil {
|
||||
if err.Error() == "no details found" {
|
||||
c.Status(http.StatusNoContent) // Item not found in queue.. missing
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.GET("/info/:requestId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := getSonarrRequestInfo(b.db, uint(requestId))
|
||||
if err != nil {
|
||||
if err.Error() == "request deleted" {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "request deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addRadarrRoutes() {
|
||||
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db), AdminRequired())
|
||||
s := b.rg.Group("/arr/rad").Use(AuthRequired(b.db))
|
||||
|
||||
// Routes are manually given AdminRequired or PermRequired middleware.
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", func(c *gin.Context) {
|
||||
s.POST("/test", AdminRequired(), func(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1044,7 +1368,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
})
|
||||
|
||||
// Get config for specific server
|
||||
s.GET("/config/:name", func(c *gin.Context) {
|
||||
s.GET("/config/:name", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
server, err := getRadarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -1058,7 +1382,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
})
|
||||
|
||||
s.POST("/add", func(c *gin.Context) {
|
||||
s.POST("/add", AdminRequired(), func(c *gin.Context) {
|
||||
var ur RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1073,7 +1397,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.POST("/edit", func(c *gin.Context) {
|
||||
s.POST("/edit", AdminRequired(), func(c *gin.Context) {
|
||||
var ur RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
@@ -1088,7 +1412,7 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.POST("/rm/:name", func(c *gin.Context) {
|
||||
s.POST("/rm/:name", AdminRequired(), func(c *gin.Context) {
|
||||
err := rmRadarr(c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
@@ -1097,27 +1421,165 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
|
||||
s.GET("", func(c *gin.Context) {
|
||||
s.GET("", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response := getRadarrsSafe()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request", func(c *gin.Context) {
|
||||
s.POST("/request", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
perms := c.GetInt("userPermissions")
|
||||
response, err := createRadarrRequest(b.db, userId, perms, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
err = radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/request/:tmdbId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getArrRequestByTmdbId(b.db, MOVIE, tmdbId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.POST("/request/approve/:id", PermRequired(PERM_ADMIN), func(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := approveRadarrRequest(b.db, uint(requestId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
s.GET("/status/:serverName/:arrId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
response, err := getRadarrQueueDetails(c.Param("serverName"), c.Param("arrId"))
|
||||
if err != nil {
|
||||
if err.Error() == "no details found" {
|
||||
c.Status(http.StatusNoContent) // Item not found in queue.. missing
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
s.GET("/info/:requestId", PermRequired(PERM_REQUEST_CONTENT), func(c *gin.Context) {
|
||||
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := getRadarrRequestInfo(b.db, uint(requestId))
|
||||
if err != nil {
|
||||
if err.Error() == "request deleted" {
|
||||
c.JSON(http.StatusNotFound, ErrorResponse{Error: "request deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addArrRequestRoutes() {
|
||||
s := b.rg.Group("/arr/request").Use(AuthRequired(b.db))
|
||||
|
||||
// Get all requests (for manage_requests view), only for admins.
|
||||
s.GET("/", AdminRequired(), func(c *gin.Context) {
|
||||
response, err := getArrRequests(b.db)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
// Deny a request (for manage_requests view), only for admins.
|
||||
s.POST("/deny/:id", AdminRequired(), func(c *gin.Context) {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
err = denyArrRequest(b.db, uint(requestId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJobRoutes() {
|
||||
job := b.rg.Group("/job").Use(AuthRequired(nil))
|
||||
|
||||
// Uses wildcard so it still works in cases where the job id includes a /.
|
||||
// (yes i changed this instead of not allowing a / when we generate a job id becuz easier)
|
||||
job.GET("/*id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
// When we get id param, don't include first letter, which will be the beginning '/'.
|
||||
response, err := getJob(c.Param("id")[1:], userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, *response)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addTaskRoutes() {
|
||||
task := b.rg.Group("/task").Use(AuthRequired(b.db), AdminRequired())
|
||||
|
||||
task.GET("/", func(c *gin.Context) {
|
||||
response := getAllTasks()
|
||||
c.JSON(http.StatusOK, response)
|
||||
})
|
||||
|
||||
task.PUT(":name", func(c *gin.Context) {
|
||||
if c.Param("name") == "" {
|
||||
c.JSON(http.StatusBadRequest, ErrorResponse{Error: "no task name provided"})
|
||||
return
|
||||
}
|
||||
var rr TaskRescheduleRequest
|
||||
err := c.ShouldBindJSON(&rr)
|
||||
if err == nil {
|
||||
err := rescheduleTask(c.Param("name"), rr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
@@ -1125,16 +1587,90 @@ func (b *BaseRouter) addRadarrRoutes() {
|
||||
})
|
||||
}
|
||||
|
||||
func (b *BaseRouter) addJobRoutes() {
|
||||
job := b.rg.Group("/job").Use(AuthRequired(nil))
|
||||
func (b *BaseRouter) addTagRoutes() {
|
||||
tag := b.rg.Group("/tag").Use(AuthRequired(nil))
|
||||
|
||||
job.GET("/:id", func(c *gin.Context) {
|
||||
// Get list of all our tags.
|
||||
tag.GET("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getJob(c.Param("id"), userId)
|
||||
tags, err := getTags(b.db, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()})
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, *response)
|
||||
c.JSON(http.StatusOK, tags)
|
||||
})
|
||||
|
||||
// // Get all items within one of our tags.
|
||||
// tag.GET(":id", func(c *gin.Context) {
|
||||
// id, err := strconv.Atoi(c.Param("id"))
|
||||
// if err != nil {
|
||||
// slog.Error("getTag route failed to convert id param to int", "error", err)
|
||||
// c.Status(http.StatusBadRequest)
|
||||
// return
|
||||
// }
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
// tags, err := getTag(b.db, userId, uint(id))
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
// return
|
||||
// }
|
||||
// c.JSON(http.StatusOK, tags)
|
||||
// })
|
||||
|
||||
// Create a tag.
|
||||
tag.POST("", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var tr TagAddRequest
|
||||
err := c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
response, err := addTag(b.db, userId, tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
// Update a tag.
|
||||
tag.PUT(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag update rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
var tr TagAddRequest
|
||||
err = c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
err := updateTag(b.db, userId, uint(id), tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()})
|
||||
})
|
||||
|
||||
tag.DELETE(":id", func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag delete rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
err = deleteTag(b.db, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// I think tags will be private for the user.
|
||||
// If the user wants to make a public list, they should make a custom view.
|
||||
|
||||
type Tag struct {
|
||||
GormModel
|
||||
// ID of user that own this tag.
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// Name of the tag.
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
// Hex of text color.
|
||||
Color string `json:"color"`
|
||||
// Hex of background color.
|
||||
BgColor string `json:"bgColor"`
|
||||
// All watched items.
|
||||
Watched []Watched `json:"watched,omitempty" gorm:"many2many:watched_tags;"`
|
||||
}
|
||||
|
||||
type TagAddRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
BgColor string `json:"bgColor"`
|
||||
}
|
||||
|
||||
func getTags(db *gorm.DB, userId uint) ([]Tag, error) {
|
||||
tags := new([]Tag)
|
||||
res := db.Model(&Tag{}).Where("user_id = ?", userId).Find(&tags)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTags: Failed getting tags from database", "error", res.Error.Error())
|
||||
return []Tag{}, errors.New("failed getting tags")
|
||||
}
|
||||
return *tags, nil
|
||||
}
|
||||
|
||||
// func getTag(db *gorm.DB, userId uint, tagId uint) (Tag, error) {
|
||||
// tag := new(Tag)
|
||||
// res := db.Model(&Tag{}).Where("id = ? AND user_id = ?", tagId, userId).Preload("Watched").Find(&tag)
|
||||
// if res.Error != nil {
|
||||
// slog.Error("getTag: Failed getting tag from database", "error", res.Error.Error())
|
||||
// return Tag{}, errors.New("failed getting tag")
|
||||
// }
|
||||
// if tag.ID == 0 {
|
||||
// slog.Error("getTag: Tag does not exist for this user.", "user_id", userId)
|
||||
// return Tag{}, errors.New("tag does not exist")
|
||||
// }
|
||||
// return *tag, nil
|
||||
// }
|
||||
|
||||
// This method should only be used when we don't have the tagId
|
||||
// (eg: when we are importing data) because this is not technically
|
||||
// reliable, since users can have multiple tags with the same name/colors
|
||||
// (realistically they probably won't, but...).
|
||||
func getTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (Tag, error) {
|
||||
tag := new(Tag)
|
||||
res := db.Model(&Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed getting tag")
|
||||
}
|
||||
if tag.ID == 0 {
|
||||
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
|
||||
return Tag{}, errors.New("tag does not exist")
|
||||
}
|
||||
return *tag, nil
|
||||
}
|
||||
|
||||
// Let user create a tag.
|
||||
func addTag(db *gorm.DB, userId uint, tr TagAddRequest) (Tag, error) {
|
||||
if tr.Name == "" {
|
||||
return Tag{}, errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{UserID: userId, Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Create(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding tag to database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed adding new tag to database")
|
||||
}
|
||||
slog.Debug("Adding tag", "added_tag", tag)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Let user update one of their tags (replaces).
|
||||
func updateTag(db *gorm.DB, userId uint, tagId uint, tr TagAddRequest) error {
|
||||
if tr.Name == "" {
|
||||
return errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Where("id = ? AND user_id = ?", tagId, userId).Updates(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating tag in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating tag in database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("updateTag: Zero rows affected.. tag likely does not exist", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
slog.Debug("updateTag:", "updated_tag", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Let user delete their own tag.
|
||||
func deleteTag(db *gorm.DB, userId uint, tagId uint) error {
|
||||
if tagId == 0 {
|
||||
return errors.New("no tag id provided")
|
||||
}
|
||||
slog.Debug("deleteTag:", "tag_id", tagId, "user_id", userId)
|
||||
// Select("Watched") so relations in watched_tags table are removed too.
|
||||
// ID is passed in the .Delete param so the .Select call can do it's job (relies on the primary key).
|
||||
res := db.Unscoped().Where("id = ? AND user_id = ?", tagId, userId).Select("Watched").Delete(&Tag{GormModel: GormModel{ID: tagId}})
|
||||
if res.Error != nil {
|
||||
slog.Error("deleteTag: Error deleting tag from database", "error", res.Error.Error(), "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("failed deleting tag from database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("deleteTag: Zero rows affected.. tag must not exist for user", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add watched content to a tag (user must own the tag and watched entry).
|
||||
func addWatchedToTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("addWatchedToTag: Adding", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Save relation (unique restraint will fail if it already exists)
|
||||
w.Tags = append(w.Tags, t)
|
||||
resp := db.Save(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to tag watched item", "error", resp.Error)
|
||||
return errors.New("failed to tag watched item")
|
||||
}
|
||||
slog.Debug("addWatchedToTag: watched content successfully linked to tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove watched content from a tag (user must own the tag and watched entry).
|
||||
func rmWatchedFromTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("rmWatchedFromTag: Removing", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Remove relation
|
||||
err := db.Model(&w).Association("Tags").Delete(&t)
|
||||
if err != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to untag watched item", "error", err)
|
||||
return errors.New("failed to untag watched item")
|
||||
}
|
||||
slog.Debug("rmWatchedFromTag: watched content successfully removed from tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
@@ -1,35 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TaskRescheduleRequest struct {
|
||||
// Number of seconds inbetween each run of this task.
|
||||
Seconds int `json:"seconds" binding:"required"`
|
||||
}
|
||||
|
||||
type AllTasksResponse struct {
|
||||
// The tasks name.
|
||||
Name string `json:"name"`
|
||||
// When this task will next run.
|
||||
NextRun time.Time `json:"nextRun"`
|
||||
// Current schedule for this task (seconds).
|
||||
Seconds int `json:"seconds"`
|
||||
}
|
||||
|
||||
type TaskFunc struct {
|
||||
// Task function.
|
||||
f func()
|
||||
// Default duration (schedule) for task.
|
||||
dd time.Duration
|
||||
}
|
||||
|
||||
var taskScheduler gocron.Scheduler
|
||||
|
||||
// All task functions are stored here so when updating (rescheduling)
|
||||
// a job, we can give it this function again.
|
||||
// Doesn't seem to be a way to only update the schedule of a job,
|
||||
// the .Update func wants the whole definition again.
|
||||
//
|
||||
// All funcs simply call a cleaning/routine method where the rest of the
|
||||
// related code lives so it's kept tidy.
|
||||
var taskFuncs map[string]TaskFunc
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(db *gorm.DB) {
|
||||
go setupMinutelyTasks(db)
|
||||
go setupDailyTasks(db)
|
||||
}
|
||||
|
||||
func setupMinutelyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 1 * time.Minute
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
// Runs funcs that are in the place where we are cleaning.
|
||||
// Bit cleaner and we can keep the related code close to its home.
|
||||
cleanupTokens(db)
|
||||
ts, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
slog.Error("SetupTasks: Failed to create new scheduler!", "error", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
taskScheduler = ts
|
||||
|
||||
func setupDailyTasks(db *gorm.DB) {
|
||||
taskRunInterval := 24 * time.Hour
|
||||
ticker := time.NewTicker(taskRunInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
cleanupImages(db)
|
||||
// Define all task funcs.
|
||||
taskFuncs = map[string]TaskFunc{
|
||||
"Cleanup Tokens": {
|
||||
f: func() {
|
||||
cleanupTokens(db)
|
||||
},
|
||||
dd: 60 * time.Second,
|
||||
},
|
||||
"Refresh Arr Queues": {
|
||||
f: func() {
|
||||
refreshArrQueues()
|
||||
},
|
||||
dd: 60 * time.Second,
|
||||
},
|
||||
"Cleanup Images": {
|
||||
f: func() {
|
||||
cleanupImages(db)
|
||||
},
|
||||
dd: 24 * time.Hour,
|
||||
},
|
||||
}
|
||||
|
||||
// Add all jobs to scheduler.
|
||||
for k, v := range taskFuncs {
|
||||
err = addTaskToScheduler(k, v.dd)
|
||||
if err != nil {
|
||||
slog.Error("SetupTasks: Failed to add new job", "job", k, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
taskScheduler.Start()
|
||||
slog.Info("SetupTasks: Jobs created and scheduler started.")
|
||||
}
|
||||
|
||||
// Gets schedule from config, or `defaultDur` if not manually configured.
|
||||
func getTaskSeconds(name string, defaultDur time.Duration) time.Duration {
|
||||
s := defaultDur
|
||||
if Config.TASK_SCHEDULE[name] != 0 {
|
||||
s = time.Duration(Config.TASK_SCHEDULE[name]) * time.Second
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Add new job to scheduler.
|
||||
func addTaskToScheduler(name string, defaultDur time.Duration) error {
|
||||
s := getTaskSeconds(name, defaultDur)
|
||||
_, err := taskScheduler.NewJob(
|
||||
gocron.DurationJob(s),
|
||||
gocron.NewTask(taskFuncs[name].f),
|
||||
gocron.WithName(name),
|
||||
)
|
||||
slog.Debug("addTaskToScheduler: Job added.", "job_name", name, "duration_used", s, "duration_default", defaultDur)
|
||||
return err
|
||||
}
|
||||
|
||||
// Get all tasks in a consumable format.
|
||||
func getAllTasks() []AllTasksResponse {
|
||||
jobs := []AllTasksResponse{}
|
||||
for _, j := range taskScheduler.Jobs() {
|
||||
j2a := AllTasksResponse{
|
||||
Name: j.Name(),
|
||||
}
|
||||
nextRun, err := j.NextRun()
|
||||
if err != nil {
|
||||
slog.Error("getAllTasks: Failed to get next run time for a job.", "job_name", j2a.Name)
|
||||
} else {
|
||||
j2a.NextRun = nextRun
|
||||
}
|
||||
j2a.Seconds = int(getTaskSeconds(j2a.Name, taskFuncs[j2a.Name].dd).Seconds())
|
||||
jobs = append(jobs, j2a)
|
||||
}
|
||||
return jobs
|
||||
}
|
||||
|
||||
// Get task (job) from scheduler by name.
|
||||
func getTask(name string) *gocron.Job {
|
||||
var job *gocron.Job
|
||||
for _, j := range taskScheduler.Jobs() {
|
||||
if j.Name() == name {
|
||||
job = &j
|
||||
break
|
||||
}
|
||||
}
|
||||
return job
|
||||
}
|
||||
|
||||
// Reschedule a task by name.
|
||||
func rescheduleTask(name string, req TaskRescheduleRequest) error {
|
||||
if req.Seconds == 0 {
|
||||
return errors.New("request has no seconds")
|
||||
}
|
||||
j := getTask(name)
|
||||
if j == nil {
|
||||
return errors.New("no task found")
|
||||
}
|
||||
// Update config
|
||||
if Config.TASK_SCHEDULE == nil {
|
||||
Config.TASK_SCHEDULE = map[string]int{}
|
||||
}
|
||||
Config.TASK_SCHEDULE[name] = req.Seconds
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("rescheduleTask: Failed to write updated config to file!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
// Update job in scheduler
|
||||
_, err := taskScheduler.Update(
|
||||
(*j).ID(),
|
||||
gocron.DurationJob(
|
||||
time.Duration(req.Seconds)*time.Second,
|
||||
),
|
||||
gocron.NewTask(
|
||||
taskFuncs[name].f,
|
||||
),
|
||||
gocron.WithName(name),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("rescheduleTask: Failed to update job!", "error", err)
|
||||
return errors.New("failed to update job")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,86 @@ type TMDBSearchMultiResults struct {
|
||||
OriginCountry []string `json:"origin_country,omitempty"`
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Title string `json:"title"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
ID int `json:"id"`
|
||||
OriginCountry []string `json:"origin_country"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Overview string `json:"overview"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
Name string `json:"name"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
ID int `json:"id"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
Name string `json:"name"`
|
||||
OriginalName string `json:"original_name"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
MediaType string `json:"media_type"` // API req doesn't include this, we will add it in our service.
|
||||
KnownFor []struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
OriginalTitle string `json:"original_title"`
|
||||
Overview string `json:"overview"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
MediaType string `json:"media_type"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
Popularity float64 `json:"popularity"`
|
||||
ReleaseDate string `json:"release_date"`
|
||||
Video bool `json:"video"`
|
||||
VoteAverage float64 `json:"vote_average"`
|
||||
VoteCount int `json:"vote_count"`
|
||||
} `json:"known_for"`
|
||||
} `json:"results"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBContentDetails struct {
|
||||
ID int `json:"id"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
@@ -491,6 +571,14 @@ type TMDBKeywords struct {
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBRegions struct {
|
||||
Results []struct {
|
||||
ISO3166_1 string `json:"iso_3166_1"`
|
||||
English_Name string `json:"english_name"`
|
||||
Native_Name string `json:"native_name"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func getTMDBKey() string {
|
||||
if Config.TMDB_KEY != "" {
|
||||
return Config.TMDB_KEY
|
||||
|
||||
@@ -58,12 +58,26 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
if ur.IncludePreviouslyWatched != nil {
|
||||
user.IncludePreviouslyWatched = ur.IncludePreviouslyWatched
|
||||
}
|
||||
if ur.AutomateShowStatuses != nil {
|
||||
user.AutomateShowStatuses = ur.AutomateShowStatuses
|
||||
}
|
||||
if ur.Country != nil {
|
||||
user.Country = ur.Country
|
||||
}
|
||||
if ur.RatingSystem != nil {
|
||||
user.RatingSystem = ur.RatingSystem
|
||||
}
|
||||
if ur.RatingStep != nil {
|
||||
user.RatingStep = ur.RatingStep
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
AutomateShowStatuses: user.AutomateShowStatuses,
|
||||
Country: user.Country,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -80,6 +94,10 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
IncludePreviouslyWatched: user.IncludePreviouslyWatched,
|
||||
AutomateShowStatuses: user.AutomateShowStatuses,
|
||||
Country: user.Country,
|
||||
RatingSystem: user.RatingSystem,
|
||||
RatingStep: user.RatingStep,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User details wanted for management views.
|
||||
type ManagedUser struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
Private bool `json:"private"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Permissions *int `json:"permissions"`
|
||||
}
|
||||
|
||||
func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
users := []ManagedUser{}
|
||||
if res := db.Model(&User{}).Find(&users); res.Error != nil {
|
||||
slog.Error("getAllUsers: Failed to fetch users from database", "error", res.Error)
|
||||
return []ManagedUser{}, errors.New("failed to fetch users from database")
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Update a user. For management views, for admin to update another user.
|
||||
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
// Error now if no userId or any UpdateUserRequest property was provided.
|
||||
if userId == 0 || (ur.Permissions == nil) {
|
||||
slog.Error("manageUser: invalid arguments", "user_id", userId)
|
||||
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
|
||||
}
|
||||
toUpdate := map[string]interface{}{}
|
||||
if ur.Permissions != nil {
|
||||
if *ur.Permissions == 0 {
|
||||
// If removing all perms, set to default of 1 (PERM_NONE).
|
||||
// Will avoid confusion and possibly bugs later on, though I doubt
|
||||
// we'd ever be (directly) checking a user to ensure they have no perms.
|
||||
toUpdate["permissions"] = PERM_NONE
|
||||
} else {
|
||||
toUpdate["permissions"] = *ur.Permissions
|
||||
}
|
||||
}
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
|
||||
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update user in database")
|
||||
}
|
||||
slog.Debug("manageUser: A user has been updated", "user_id", userId)
|
||||
return nil
|
||||
}
|
||||
@@ -52,7 +52,7 @@ func main() {
|
||||
setLoggingLevel()
|
||||
|
||||
// Ensure data dir exists
|
||||
err = ensureDirExists("./data")
|
||||
err = ensureDirExists(DataPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create data dir:", err)
|
||||
}
|
||||
@@ -64,13 +64,14 @@ func main() {
|
||||
isProd = false
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open("./data/watcharr.db"), &gorm.Config{TranslateError: true})
|
||||
db, err := gorm.Open(sqlite.Open(path.Join(DataPath, "watcharr.db")), &gorm.Config{TranslateError: true})
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&UserServices{},
|
||||
&Content{},
|
||||
&Watched{},
|
||||
&WatchedSeason{},
|
||||
@@ -80,6 +81,8 @@ func main() {
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
&ArrRequest{},
|
||||
&Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
@@ -92,10 +95,23 @@ func main() {
|
||||
gin.DefaultWriter = multiw
|
||||
gine := gin.Default()
|
||||
gine.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization", "accept", "origin", "Cache-Control", "X-Requested-With"},
|
||||
ExposeHeaders: []string{"Content-Length"},
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Accept-Encoding",
|
||||
"X-CSRF-Token",
|
||||
"Authorization",
|
||||
"accept",
|
||||
"origin",
|
||||
"Cache-Control",
|
||||
"X-Requested-With",
|
||||
},
|
||||
ExposeHeaders: []string{
|
||||
"Content-Length",
|
||||
"watcharr-lastviewedseason-saved",
|
||||
},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 12 * time.Hour,
|
||||
}))
|
||||
@@ -131,6 +147,7 @@ func main() {
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addPlexRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addFollowRoutes()
|
||||
br.addImportRoutes()
|
||||
@@ -138,7 +155,10 @@ func main() {
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.addArrRequestRoutes()
|
||||
br.addJobRoutes()
|
||||
br.addTaskRoutes()
|
||||
br.addTagRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
@@ -150,7 +170,7 @@ func main() {
|
||||
func setupLogging() io.Writer {
|
||||
// logLevel = new(slog.LevelVar)
|
||||
multiw := io.MultiWriter(&lumberjack.Logger{
|
||||
Filename: "./data/watcharr.log",
|
||||
Filename: path.Join(DataPath, "watcharr.log"),
|
||||
MaxSize: 1, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
|
||||
@@ -21,15 +21,19 @@ const (
|
||||
FINISHED WatchedStatus = "FINISHED"
|
||||
WATCHING WatchedStatus = "WATCHING"
|
||||
PLANNED WatchedStatus = "PLANNED"
|
||||
HOLD WatchedStatus = "ONHOLD"
|
||||
HOLD WatchedStatus = "HOLD"
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
// float so we can support decimal ratings.
|
||||
// Ratings should still always be saved as out of 10.0,
|
||||
// so they can be viewed with any ratings setting in the client.
|
||||
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Pinned bool `json:"pinned" gorm:"default:false;not null"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
@@ -38,11 +42,15 @@ type Watched struct {
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:watched_tags;"`
|
||||
// The last season that was viewed by the user for this watched entry.
|
||||
// Only applies to tv shows of course.
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
@@ -52,10 +60,11 @@ type WatchedAddRequest struct {
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts"`
|
||||
Rating int8 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts"`
|
||||
Status WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
|
||||
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
@@ -68,13 +77,33 @@ type WatchedRemoveResponse struct {
|
||||
|
||||
func getWatched(db *gorm.DB, userId uint) []Watched {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Preload("WatchedSeasons").Preload("WatchedEpisodes").Where("user_id = ?", userId).Find(&watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched
|
||||
}
|
||||
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
func getWatchedItemById(db *gorm.DB, userId uint, id uint) (Watched, error) {
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Where("user_id = ? AND id = ?", userId, id).Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemById: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
@@ -85,16 +114,16 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
|
||||
// if this is a thing we need but its here.. for now at least.
|
||||
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get user for getPublicWatched request")
|
||||
slog.Error("Failed to get user for getPublicWatched request", "user_id", userId)
|
||||
return []Watched{}, errors.New("failed to check privacy settings")
|
||||
}
|
||||
if user.Private != nil && *user.Private {
|
||||
slog.Error("getPublicWatched attempted to get a private list")
|
||||
slog.Error("getPublicWatched attempted to get a private list", "user_id", userId)
|
||||
return []Watched{}, errors.New("this watched list is private")
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Where("user_id = ?", userId).Find(&watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
@@ -103,45 +132,10 @@ func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, err
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
|
||||
var content Content
|
||||
db.Where("type = ? AND tmdb_id = ?", ar.ContentType, ar.ContentID).Find(&content)
|
||||
|
||||
// Create content if not found from our db
|
||||
if content == (Content{}) {
|
||||
slog.Debug("Content not in db, fetching...")
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(ar.ContentType)+"/"+strconv.Itoa(ar.ContentID), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("addWatched content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
if ar.ContentType == "movie" {
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Watched{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("addWatched failed to cache movie content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, 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 Watched{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("addWatched failed to cache tv content", "content_id", ar.ContentID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
// Get content cache (or cache it if we don't have it locally)
|
||||
content, err := getOrCacheContent(db, ar.ContentType, ar.ContentID)
|
||||
if err != nil {
|
||||
return Watched{}, err
|
||||
}
|
||||
// Error if content has no id
|
||||
if content.ID == 0 {
|
||||
@@ -229,6 +223,9 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
if ar.RemoveThoughts {
|
||||
upwat.Thoughts = ""
|
||||
}
|
||||
if ar.Pinned != nil {
|
||||
upwat.Pinned = *ar.Pinned
|
||||
}
|
||||
res = db.Save(upwat)
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
@@ -249,6 +246,24 @@ func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func updateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error {
|
||||
slog.Debug("UpdateWatchedLastViewedSeason", "user_id", userId, "id", id, "season_num", seasonNum)
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where("id = ? AND user_id = ?", id, userId).
|
||||
Update("last_viewed_season", seasonNum)
|
||||
if res.Error != nil {
|
||||
slog.Error("updateWatchedLastViewedSeason: Failed when updating.", "error", res.Error)
|
||||
return errors.New("failed to update db")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// likely the watched entry does not exist or is not owned by this `userId`.
|
||||
slog.Error("updateWatchedLastViewedSeason: Watched entry does not exist.")
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
@@ -267,45 +282,67 @@ func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, er
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func download(url string, outf string) (err error) {
|
||||
slog.Debug("Attempting to download file", "url", url, "outf", outf)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Download file over http (used for downloading poster images)
|
||||
// url - The remote file url.
|
||||
// outf - Where should we store the downloaded file.
|
||||
// force - Should we overwrite an existing file? If false, existing files will be skipped.
|
||||
func download(url string, outf string, force bool) (err error) {
|
||||
slog.Debug("download: Attempting to download file", "url", url, "outf", outf, "force", force)
|
||||
// If not forced, skip call if file already exists to save unnecessary requests.
|
||||
if !force {
|
||||
if _, err := os.Stat(outf); !errors.Is(err, os.ErrNotExist) {
|
||||
slog.Debug("download: Skipping file, it already exists locally.", "outf", outf, "error", err)
|
||||
return nil
|
||||
} else {
|
||||
return err
|
||||
slog.Debug("download: Continuing to download file, it does not already exist.", "outf", outf, "error", err)
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to make request.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Error("download: Request failed. Non OK response.", "outf", outf, "status", resp.Status, "error", err)
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("download: Failed to create out file, trying to recover by ensuring directories exist.", "outf", outf)
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create dir(s) in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create out file again in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("download: recovered by creating dir(s).", "outf", outf)
|
||||
} else {
|
||||
slog.Error("download: Failed to create out file. No known recovery path possible.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to write file to our file.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("download: Successfully downloaded file", "outf", outf)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ type WatchedEpisodeAddRequest struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
}
|
||||
@@ -39,6 +39,8 @@ type WatchedEpisodeAddRequest struct {
|
||||
type WatchedEpisodeAddResponse struct {
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
AddedActivity Activity `json:"addedActivity"`
|
||||
// Response from hook
|
||||
EpisodeStatusChangedHookResponse EpisodeStatusChangedHookResponse `json:"episodeStatusChangedHookResponse,omitempty"`
|
||||
}
|
||||
|
||||
// Add/edit a watched episode.
|
||||
@@ -115,10 +117,15 @@ func addWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (
|
||||
}
|
||||
addedActivity, _ = addActivity(db, userId, act)
|
||||
}
|
||||
return WatchedEpisodeAddResponse{
|
||||
episodeAddResp := WatchedEpisodeAddResponse{
|
||||
WatchedEpisodes: w.WatchedEpisodes,
|
||||
AddedActivity: addedActivity,
|
||||
}, nil
|
||||
}
|
||||
if ar.Status != "" {
|
||||
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
|
||||
episodeAddResp.EpisodeStatusChangedHookResponse = hookEpisodeStatusChanged(db, userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
|
||||
}
|
||||
return episodeAddResp, nil
|
||||
}
|
||||
|
||||
// Remove a watched episode
|
||||
@@ -147,3 +154,11 @@ func rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (Activity, error) {
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
|
||||
func getNumberOfWatchedEpisodesInSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int, acceptableStatus []WatchedStatus) (int64, error) {
|
||||
var count int64
|
||||
if res := db.Model(&WatchedEpisode{}).Where("user_id = ? AND watched_id = ? AND season_number = ? AND status IN ?", userId, watchedId, seasonNumber, acceptableStatus).Count(&count); res.Error != nil {
|
||||
return 0, res.Error
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type EpisodeStatusChangedHookResponse struct {
|
||||
// The watched shows status if we modified it.
|
||||
NewShowStatus WatchedStatus `json:"newShowStatus,omitempty"`
|
||||
// The full watched season (if created or modified).
|
||||
WatchedSeason *WatchedSeason `json:"watchedSeason,omitempty"`
|
||||
// All activies we have added.
|
||||
AddedActivities []Activity `json:"addedActivities,omitempty"`
|
||||
// All errors (fatal and non-fatal) that were encountered.
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// Called after an episode watched status has been set.
|
||||
func hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNum int, episodeNum int, newEpisodeStatus WatchedStatus) EpisodeStatusChangedHookResponse {
|
||||
userSettings, err := userGetSettings(db, userId)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get user settings! Hook will continue.", "error", err)
|
||||
} else {
|
||||
if !*userSettings.AutomateShowStatuses {
|
||||
slog.Debug("hookEpisodeStatusChanged: User has AutomateShowStatuses disabled. Skipping hook.", "user_id", userId)
|
||||
return EpisodeStatusChangedHookResponse{}
|
||||
}
|
||||
}
|
||||
|
||||
hookResponse := EpisodeStatusChangedHookResponse{}
|
||||
|
||||
addHookActivity := func(aType ActivityType, data string) {
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
|
||||
}
|
||||
|
||||
// 2. If the season (this episode is in) has no status or is planned, set season to watching.
|
||||
watchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Cannot continue, failed to get watchedSeason!", "error", err)
|
||||
return EpisodeStatusChangedHookResponse{Errors: []string{("failed to query db for watched season")}}
|
||||
}
|
||||
// If season not found, create it.
|
||||
if watchedSeason == nil {
|
||||
slog.Debug("hookEpisodeStatusChanged: Watched season does not exist. Creating now.")
|
||||
seasonStatus := newEpisodeStatus
|
||||
if newEpisodeStatus == FINISHED || newEpisodeStatus == DROPPED {
|
||||
seasonStatus = WATCHING
|
||||
}
|
||||
resp, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
addActivity: SEASON_ADDED_AUTO,
|
||||
addActivityData: map[string]interface{}{"reason": fmt.Sprintf("Episode %d was set to %s while the season had no status.", episodeNum, newEpisodeStatus)},
|
||||
WatchedID: watchedId,
|
||||
SeasonNumber: seasonNum,
|
||||
Status: seasonStatus,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to add watched season!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to add watched season")
|
||||
} else {
|
||||
// addWatchedSeason returns all watched seasons, get the one just added. (may be best to retrofit addWatchedSeason later to return id of season/row created)
|
||||
justAddedWatchedSeason, err := getWatchedSeason(db, userId, watchedId, seasonNum)
|
||||
if err != nil {
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get newly added watched season for response")
|
||||
} else {
|
||||
watchedSeason = justAddedWatchedSeason
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
}
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, resp.AddedActivity)
|
||||
}
|
||||
} else if watchedSeason.Status == "" || watchedSeason.Status == PLANNED ||
|
||||
((newEpisodeStatus == FINISHED || newEpisodeStatus == WATCHING) && (watchedSeason.Status == HOLD || watchedSeason.Status == DROPPED)) {
|
||||
reasonStr := fmt.Sprintf("Episode %d was set to %s while the season had ", episodeNum, newEpisodeStatus)
|
||||
if watchedSeason.Status == "" {
|
||||
reasonStr += "no status."
|
||||
} else {
|
||||
reasonStr += fmt.Sprintf("a status of %s.", watchedSeason.Status)
|
||||
}
|
||||
watchedSeason.Status = WATCHING
|
||||
if res := db.Save(watchedSeason); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update season status!", "error", res.Error)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status")
|
||||
} else {
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": watchedSeason.Status, "reason": reasonStr})
|
||||
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If the show has no status or is planned, set it to watching.
|
||||
watchedShow, err := getWatchedItemById(db, userId, watchedId)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get watched show, cant continue to update show status.", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get watched item for show")
|
||||
return hookResponse
|
||||
} else {
|
||||
// Show status shouldn't be empty, but watevs, handle it just incase
|
||||
if watchedShow.Status == "" || watchedShow.Status == PLANNED {
|
||||
watchedShow.Status = WATCHING
|
||||
if res := db.Save(watchedShow); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update show status!", "error", res.Error)
|
||||
} else {
|
||||
hookResponse.NewShowStatus = watchedShow.Status
|
||||
json, _ := json.Marshal(map[string]interface{}{"status": watchedShow.Status, "reason": fmt.Sprintf("S%dE%d was set to %s.", seasonNum, episodeNum, newEpisodeStatus)})
|
||||
addHookActivity(STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If all episodes are FINISHED or DROPPED, set the season to FINISHED
|
||||
// BUG If a seasons status is removed and the last episode of the season is marked finished,
|
||||
// this will add activity for the season being marked finished, right after it is set
|
||||
// to Watching just above. I think this might never happen to anyone so um ye.
|
||||
tmdbIdStr := strconv.Itoa(watchedShow.Content.TmdbID)
|
||||
seasonNumStr := strconv.Itoa(seasonNum)
|
||||
seasonDetails, err := seasonDetails(tmdbIdStr, seasonNumStr)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get season details!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get season details for show")
|
||||
return hookResponse
|
||||
}
|
||||
allEpisodesCount := len(seasonDetails.Episodes)
|
||||
finishedEpisodesCount, err := getNumberOfWatchedEpisodesInSeason(db, userId, watchedId, seasonNum, []WatchedStatus{FINISHED, DROPPED})
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get number of watched episodes in this season!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get number of watched episodes in this season")
|
||||
return hookResponse
|
||||
}
|
||||
slog.Debug("hookEpisodeStatusChanged: Got episode counts.", "allEpisodesCount", allEpisodesCount, "finishedEpisodesCount", finishedEpisodesCount)
|
||||
if finishedEpisodesCount >= int64(allEpisodesCount) {
|
||||
slog.Debug("hookEpisodeStatusChanged: All episodes have been completed (finished or dropped). Marking season finished.")
|
||||
newStatus := FINISHED
|
||||
if watchedSeason != nil && watchedSeason.Status == newStatus {
|
||||
slog.Debug("hookEpisodeStatusChanged: WatchedSeason status is same as newStatus so not updating.")
|
||||
return hookResponse
|
||||
}
|
||||
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNum, userId).Update("status", newStatus); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update season status to finished:", "error", res.Error.Error())
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status to finished")
|
||||
return hookResponse
|
||||
} else {
|
||||
if watchedSeason != nil {
|
||||
watchedSeason.Status = newStatus
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
} else {
|
||||
slog.Error("hookEpisodeStatusChanged: watchedSeason was nil HOW DID THIS HAPPEN? Anyways the client won't be able to update its state with the new season status until it is refreshed.")
|
||||
}
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": newStatus, "reason": fmt.Sprintf("The season was deemed completed when episode %d was set to %s.", episodeNum, newEpisodeStatus)})
|
||||
addHookActivity(SEASON_STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
|
||||
return hookResponse
|
||||
}
|
||||
@@ -25,9 +25,12 @@ type WatchedSeasonAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
// Data to add to activity if the season is created.
|
||||
// Combined with data we already add.
|
||||
addActivityData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddResponse struct {
|
||||
@@ -98,7 +101,15 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
actData := map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating}
|
||||
if len(ar.addActivityData) > 0 {
|
||||
for k, v := range ar.addActivityData {
|
||||
if _, ok := ar.addActivityData[k]; ok {
|
||||
actData[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
json, _ := json.Marshal(actData)
|
||||
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
|
||||
if ar.addActivity != "" {
|
||||
act.Type = ar.addActivity
|
||||
@@ -139,3 +150,15 @@ func rmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (Activity, error)
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
|
||||
func getWatchedSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int) (*WatchedSeason, error) {
|
||||
var ws *WatchedSeason
|
||||
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNumber, userId).Take(&ws); res.Error != nil {
|
||||
slog.Error("getWatchedSeason: Failed to get:", "error", res.Error.Error())
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &WatchedSeason{}, errors.New("failed to get watched season")
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import type { Activity } from "@/types";
|
||||
import { getOrdinalSuffix, months, seasonAndEpToReadable } from "./util/helpers";
|
||||
import ActivityEditor from "./ActivityEditor.svelte";
|
||||
import Icon from "./Icon.svelte";
|
||||
import tooltip from "./actions/tooltip";
|
||||
|
||||
export let activity: Activity[] | undefined;
|
||||
export let wListId: number;
|
||||
@@ -36,6 +38,12 @@
|
||||
return `Status Changed to ${toFullTitleCase(a.data)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Status Changed to ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Status Changed";
|
||||
case "THOUGHTS_CHANGED":
|
||||
return "Thoughts Changed";
|
||||
case "THOUGHTS_REMOVED":
|
||||
@@ -43,6 +51,7 @@
|
||||
case "IMPORTED_WATCHED":
|
||||
return "Imported";
|
||||
case "IMPORTED_WATCHED_JF":
|
||||
case "IMPORTED_WATCHED_PLEX":
|
||||
return "Synced";
|
||||
case "IMPORTED_RATING":
|
||||
if (a.data) {
|
||||
@@ -56,14 +65,17 @@
|
||||
return "Imported Rating";
|
||||
case "IMPORTED_ADDED_WATCHED":
|
||||
case "IMPORTED_ADDED_WATCHED_JF":
|
||||
case "IMPORTED_ADDED_WATCHED_PLEX":
|
||||
return "Imported Watch Date";
|
||||
case "SEASON_ADDED":
|
||||
case "SEASON_ADDED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Added as ${toFullTitleCase(data.status)}`;
|
||||
}
|
||||
return "Season Added";
|
||||
case "SEASON_ADDED_JF":
|
||||
case "SEASON_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Season ${data.season} Synced as ${toFullTitleCase(data.status)}`;
|
||||
@@ -76,6 +88,7 @@
|
||||
}
|
||||
return "Season Rating Changed";
|
||||
case "SEASON_STATUS_CHANGED":
|
||||
case "SEASON_STATUS_CHANGED_AUTO":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `Changed Season ${data.season} Status to ${toFullTitleCase(data.status)}`;
|
||||
@@ -94,6 +107,7 @@
|
||||
}
|
||||
return "Episode Added";
|
||||
case "EPISODE_ADDED_JF":
|
||||
case "EPISODE_ADDED_PLEX":
|
||||
if (a.data) {
|
||||
const data = JSON.parse(a.data);
|
||||
return `${seasonAndEpToReadable(data.season, data.episode)} Synced ${data.status ? `as ${toFullTitleCase(data.status)}` : data.rating ? `with Rating ${data.rating}` : ""}`;
|
||||
@@ -168,6 +182,16 @@
|
||||
isActivityEditorVisible = true;
|
||||
return;
|
||||
}
|
||||
|
||||
function getActivityDataParsed(a: Activity) {
|
||||
try {
|
||||
if (a.data) {
|
||||
return JSON.parse(a.data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("getActivityDataParsed: Failed!", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if isActivityEditorVisible}
|
||||
@@ -193,6 +217,21 @@
|
||||
<span title={d.toDateString()}>{toDayTime(d)}</span>
|
||||
<span>{getMsg(a)}</span>
|
||||
</button>
|
||||
{#if a.type?.endsWith("_AUTO")}
|
||||
{@const data = getActivityDataParsed(a)}
|
||||
<i
|
||||
use:tooltip={{
|
||||
text:
|
||||
data && data.reason
|
||||
? `Automated because ${data.reason}`
|
||||
: "Completed by an automation.",
|
||||
pos: "bot"
|
||||
}}
|
||||
style="width: 20px; height: 20px;"
|
||||
>
|
||||
<Icon i="sparkles" wh={20} />
|
||||
</i>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/each}
|
||||
@@ -231,6 +270,11 @@
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
|
||||
button {
|
||||
all: unset;
|
||||
display: flex;
|
||||
@@ -250,7 +294,7 @@
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
&:last-of-type {
|
||||
background-color: $accent-color;
|
||||
color: $text-color;
|
||||
border-radius: 8px;
|
||||
@@ -269,11 +313,6 @@
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
margin-left: 30px;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<!-- Uses Native Color Input -->
|
||||
|
||||
<script lang="ts">
|
||||
export let value = "#000000";
|
||||
export let style = "";
|
||||
</script>
|
||||
|
||||
<button class="plain" style={`background-color: ${value};${style}`}>
|
||||
<input type="color" bind:value />
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
outline: 1px solid $accent-color;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { DropDownItem } from "@/types";
|
||||
import Icon from "./Icon.svelte";
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let options: string[] | DropDownItem[];
|
||||
// If we are using DropDownItems[] as options.
|
||||
export let isDropDownItem = false;
|
||||
export let active: string | number | undefined = undefined;
|
||||
export let placeholder: string;
|
||||
export let blendIn: boolean = false;
|
||||
@@ -11,15 +14,54 @@
|
||||
|
||||
let activeValue: string;
|
||||
let open = false;
|
||||
let ulElement: HTMLUListElement;
|
||||
let mainElement: HTMLDivElement;
|
||||
|
||||
function handleKeyPress(event: KeyboardEvent) {
|
||||
if (!open || disabled) return; // Don't handle if closed or disabled
|
||||
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter)
|
||||
);
|
||||
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f = typeof filteredOptions[0] === "string" ? filteredOptions[0] : filteredOptions[0].id;
|
||||
}
|
||||
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
if (typeof active === "string") {
|
||||
if (typeof active === "string" && !isDropDownItem) {
|
||||
activeValue = active;
|
||||
} else {
|
||||
const v = options.find((o) => (typeof o !== "string" ? o.id === active : false));
|
||||
if (v && typeof v !== "string") activeValue = v.value;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mainElement.addEventListener("keypress", handleKeyPress);
|
||||
|
||||
return () => {
|
||||
mainElement.removeEventListener("keypress", handleKeyPress);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -28,12 +70,13 @@
|
||||
typeof active === "undefined" ? "placeholder-shown" : "",
|
||||
blendIn ? "blend-in" : ""
|
||||
].join(" ")}
|
||||
bind:this={mainElement}
|
||||
>
|
||||
<button on:click={() => (open = !open)} {disabled}>
|
||||
{activeValue ? activeValue : placeholder}
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each options.filter((o) => (typeof o === "string" ? o !== active : o.id !== active)) as o}
|
||||
<li>
|
||||
<button
|
||||
@@ -111,6 +154,8 @@
|
||||
border-bottom-right-radius: 5px;
|
||||
background-color: $bg-color;
|
||||
z-index: 99;
|
||||
max-height: 20vh;
|
||||
overflow-y: auto;
|
||||
|
||||
li {
|
||||
width: 100%;
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{#if isDragOver}
|
||||
Import {text}
|
||||
{:else}
|
||||
Browse For {text}
|
||||
{text}
|
||||
{/if}
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
export let pretty: string;
|
||||
export let error: any;
|
||||
export let onRetry: () => void | undefined = undefined!;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
@@ -14,6 +15,9 @@
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
{/if}
|
||||
{#if onRetry}
|
||||
<button on:click={onRetry}>Try Again</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -46,6 +46,19 @@
|
||||
d="M195.94 459.38C205.37 472.67 221 480 240 480a16 16 0 0014.31-8.85c3-6.06 15.25-24 28.19-42.9 18-26.33 40.35-59.08 55.23-84.81l.13-.22c20.48-35.49 30.35-54.94 33.82-62l1-2a16.48 16.48 0 015.79-5.23A15.93 15.93 0 01386 272h25.32A84.7 84.7 0 00496 187.3v-38.6A84.7 84.7 0 00411.31 64h-48.79a17.46 17.46 0 01-9.58-2.89C330 46.13 286.66 32 240 32c-7.45 0-14.19.14-20.27.38a8 8 0 00-6.2 12.68l.1.14C222.2 57.59 224 71 224 80a61.16 61.16 0 01-5.19 24.77 17.38 17.38 0 000 14.06 63.81 63.81 0 010 50.39 17.32 17.32 0 000 14 62.13 62.13 0 010 49.58 18.13 18.13 0 000 14.68A60.41 60.41 0 01224 273c0 8.2-2 21.3-8 31.18a15.66 15.66 0 00-1.14 13.65c.38 1 .76 2.06 1.13 3.17a24.8 24.8 0 01.86 11.57c-3 19.35-9.67 36.3-16.74 54.16-3.08 7.78-6.27 15.82-9.22 24.27-6.14 17.56-4.3 35.2 5.05 48.38z"
|
||||
/></svg
|
||||
>
|
||||
{:else if i === "thumb-up"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472.06 334l-144.16-6.13c-4.61-.36-23.9-1.21-23.9-25.87 0-23.81 19.16-25.33 24.14-25.88L472.06 270c12.67.13 23.94 14.43 23.94 32s-11.27 31.87-23.94 32zM330.61 202.33L437.35 194C450 194 464 210.68 464 227.88v.33c0 16.32-11.14 29.62-24.88 29.79l-108.45-1.73C304 253 304 236.83 304 229.88c0-22.88 21.8-27.15 26.61-27.55zM421.85 480l-89.37-8.93C308 470.14 304 453.82 304 443.59c0-18.38 13.41-24.6 26.67-24.6l91-3c14.54.23 26.32 14.5 26.32 32s-11.67 31.67-26.14 32.01zm34.36-71.5l-126.4-6.21c-9.39-.63-25.81-3-25.81-26.37 0-12 4.35-25.61 25-27.53l127.19-3.88c13.16.14 23.81 13.49 23.81 31.4s-10.65 32.43-23.79 32.58z"
|
||||
/>
|
||||
<path
|
||||
d="M133.55 238.06A15.85 15.85 0 01126 240a15.82 15.82 0 007.51-1.92zM174.14 168.78l.13-.23-.13.23c-20.5 35.51-30.36 54.95-33.82 62 3.47-7.07 13.34-26.51 33.82-62z"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M139.34 232.84l1-2a16.27 16.27 0 01-6.77 7.25 16.35 16.35 0 005.77-5.25z" /><path
|
||||
d="M316.06 52.62C306.63 39.32 291 32 272 32a16 16 0 00-14.31 8.84c-3 6.07-15.25 24-28.19 42.91-18 26.33-40.35 59.07-55.23 84.8l-.13.23c-20.48 35.49-30.35 54.93-33.82 62l-1 2a16.35 16.35 0 01-5.79 5.22 15.82 15.82 0 01-7.53 2h-25.31A84.69 84.69 0 0016 324.69v38.61a84.69 84.69 0 0084.69 84.7h48.79a17.55 17.55 0 019.58 2.89C182 465.87 225.34 480 272 480c7.45 0 14.19-.14 20.27-.38a8 8 0 006.2-12.68l-.1-.14C289.8 454.41 288 441 288 432a61.2 61.2 0 015.19-24.77 17.36 17.36 0 000-14.05 63.81 63.81 0 010-50.39 17.32 17.32 0 000-14 62.15 62.15 0 010-49.59 18.13 18.13 0 000-14.68A60.33 60.33 0 01288 239c0-8.2 2-21.3 8-31.19a15.63 15.63 0 001.14-13.64c-.38-1-.76-2.07-1.13-3.17a24.84 24.84 0 01-.86-11.58c3-19.34 9.67-36.29 16.74-54.16 3.08-7.78 6.27-15.82 9.22-24.26 6.14-17.57 4.3-35.2-5.05-48.38z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pause"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
@@ -58,30 +71,27 @@
|
||||
d="M12 .002C8.826.002-1.398 18.537.16 21.666c1.56 3.129 22.14 3.094 23.682 0C25.384 18.573 15.177 0 12 0zm7.76 18.949c-1.008 2.028-14.493 2.05-15.514 0C3.224 16.9 9.92 4.755 12.003 4.755c2.081 0 8.77 12.166 7.759 14.196zM12 9.198c-1.054 0-4.446 6.15-3.93 7.189.518 1.04 7.348 1.027 7.86 0 .511-1.027-2.874-7.19-3.93-7.19z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "emby"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width={wh} height={wh}>
|
||||
<path
|
||||
d="M11.041 0c-.007 0-1.456 1.43-3.219 3.176L4.615 6.352l.512.513.512.512-2.819 2.791L0 12.961l1.83 1.848c1.006 1.016 2.438 2.46 3.182 3.209l1.351 1.359.508-.496c.28-.273.515-.498.524-.498.008 0 1.266 1.264 2.794 2.808L12.97 24l.187-.182c.23-.225 5.007-4.95 5.717-5.656l.52-.516-.502-.513c-.276-.282-.5-.52-.496-.53.003-.009 1.264-1.26 2.802-2.783 1.538-1.522 2.8-2.776 2.803-2.785.005-.012-3.617-3.684-6.107-6.193L17.65 4.6l-.505.505c-.279.278-.517.501-.53.497-.013-.005-1.27-1.267-2.793-2.805A449.655 449.655 0 0011.041 0zM9.223 7.367c.091.038 7.951 4.608 7.957 4.627.003.013-1.781 1.056-3.965 2.32a999.898 999.898 0 01-3.996 2.307c-.019.006-.026-1.266-.026-4.629 0-3.7.007-4.634.03-4.625Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "plex"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width={wh} height={wh}>
|
||||
<path d="M256 70H148l108 186-108 186h108l108-186z" fill="#e5a00d" />
|
||||
</svg>
|
||||
{:else if i === "trash"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M296 64h-80a7.91 7.91 0 00-8 8v24h96V72a7.91 7.91 0 00-8-8z" fill="none" />
|
||||
<path
|
||||
d="M112 112l20 320c.95 18.49 14.4 32 32 32h184c17.67 0 30.87-13.51 32-32l20-320"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M80 112h352"
|
||||
/>
|
||||
<path
|
||||
d="M192 112V72h0a23.93 23.93 0 0124-24h80a23.93 23.93 0 0124 24h0v40M256 176v224M184 176l8 224M328 176l-8 224"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="32"
|
||||
d="M432 96h-96V72a40 40 0 00-40-40h-80a40 40 0 00-40 40v24H80a16 16 0 000 32h17l19 304.92c1.42 26.85 22 47.08 48 47.08h184c26.13 0 46.3-19.78 48-47l19-305h17a16 16 0 000-32zM192.57 416H192a16 16 0 01-16-15.43l-8-224a16 16 0 1132-1.14l8 224A16 16 0 01192.57 416zM272 400a16 16 0 01-32 0V176a16 16 0 0132 0zm32-304h-96V72a7.91 7.91 0 018-8h80a7.91 7.91 0 018 8zm32 304.57A16 16 0 01320 416h-.58A16 16 0 01304 399.43l8-224a16 16 0 1132 1.14z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "close"}
|
||||
@@ -95,10 +105,24 @@
|
||||
d="M368 368L144 144M368 144L144 368"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
{:else if i === "close-circle"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
fill="currentColor"
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm75.31 260.69a16 16 0 11-22.62 22.62L256 278.63l-52.69 52.68a16 16 0 01-22.62-22.62L233.37 256l-52.68-52.69a16 16 0 0122.62-22.62L256 233.37l52.69-52.68a16 16 0 0122.62 22.62L278.63 256z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "filter-circle"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm32 304h-64a16 16 0 010-32h64a16 16 0 010 32zm48-64H176a16 16 0 010-32h160a16 16 0 010 32zm32-64H144a16 16 0 010-32h224a16 16 0 010 32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "reel"}
|
||||
@@ -196,9 +220,9 @@
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sort"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M296 464a23.88 23.88 0 01-7.55-1.23L208.3 436.1a23.92 23.92 0 01-16.3-22.78V294.11a.44.44 0 00-.09-.13L23.26 97.54A30 30 0 0146.05 48H466a30 30 0 0122.79 49.54L320.09 294a.77.77 0 00-.09.13V440a23.93 23.93 0 01-24 24z"
|
||||
d="M472 168H40a24 24 0 010-48h432a24 24 0 010 48zM392 280H120a24 24 0 010-48h272a24 24 0 010 48zM296 392h-80a24 24 0 010-48h80a24 24 0 010 48z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "eye-closed"}
|
||||
@@ -210,6 +234,12 @@
|
||||
d="M491 273.36a32.2 32.2 0 00-.1-34.76c-26.46-40.92-60.79-75.68-99.27-100.53C349 110.55 302 96 255.68 96a226.54 226.54 0 00-71.82 11.79 4 4 0 00-1.56 6.63l47.24 47.24a4 4 0 003.82 1.05 96 96 0 01116 116 4 4 0 001.05 3.81l67.95 68a4 4 0 005.4.24 343.81 343.81 0 0067.24-77.4zM256 352a96 96 0 01-93.3-118.63 4 4 0 00-1.05-3.81l-66.84-66.87a4 4 0 00-5.41-.23c-24.39 20.81-47 46.13-67.67 75.72a31.92 31.92 0 00-.64 35.54c26.41 41.33 60.39 76.14 98.28 100.65C162.06 402 207.92 416 255.68 416a238.22 238.22 0 0072.64-11.55 4 4 0 001.61-6.64l-47.47-47.46a4 4 0 00-3.81-1.05A96 96 0 01256 352z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people-nocircle"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M336 256c-20.56 0-40.44-9.18-56-25.84-15.13-16.25-24.37-37.92-26-61-1.74-24.62 5.77-47.26 21.14-63.76S312 80 336 80c23.83 0 45.38 9.06 60.7 25.52 15.47 16.62 23 39.22 21.26 63.63-1.67 23.11-10.9 44.77-26 61C376.44 246.82 356.57 256 336 256zm66-88zM467.83 432H204.18a27.71 27.71 0 01-22-10.67 30.22 30.22 0 01-5.26-25.79c8.42-33.81 29.28-61.85 60.32-81.08C264.79 297.4 299.86 288 336 288c36.85 0 71 9 98.71 26.05 31.11 19.13 52 47.33 60.38 81.55a30.27 30.27 0 01-5.32 25.78A27.68 27.68 0 01467.83 432zM147 260c-35.19 0-66.13-32.72-69-72.93-1.42-20.6 5-39.65 18-53.62 12.86-13.83 31-21.45 51-21.45s38 7.66 50.93 21.57c13.1 14.08 19.5 33.09 18 53.52-2.87 40.2-33.8 72.91-68.93 72.91zM212.66 291.45c-17.59-8.6-40.42-12.9-65.65-12.9-29.46 0-58.07 7.68-80.57 21.62-25.51 15.83-42.67 38.88-49.6 66.71a27.39 27.39 0 004.79 23.36A25.32 25.32 0 0041.72 400h111a8 8 0 007.87-6.57c.11-.63.25-1.26.41-1.88 8.48-34.06 28.35-62.84 57.71-83.82a8 8 0 00-.63-13.39c-1.57-.92-3.37-1.89-5.42-2.89z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "people"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
@@ -264,6 +294,12 @@
|
||||
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "refresh"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M256 48C141.31 48 48 141.32 48 256c0 114.86 93.14 208 208 208 114.69 0 208-93.31 208-208 0-114.87-93.13-208-208-208zm0 313a94 94 0 010-188h4.21l-14.11-14.1a14 14 0 0119.8-19.8l40 40a14 14 0 010 19.8l-40 40a14 14 0 01-19.8-19.8l18-18c-2.38-.1-5.1-.1-8.1-.1a66 66 0 1066 66 14 14 0 0128 0 94.11 94.11 0 01-94 94z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "movary"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -286,6 +322,125 @@
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else if i === "ryot"}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={wh}
|
||||
height={wh}
|
||||
viewBox="0 0 120 120"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
>
|
||||
<g transform="translate(0.000000,120.000000) scale(0.0500000,-0.0500000)" fill="currentColor">
|
||||
<path
|
||||
d="M1126 2369 c-402 -59 -745 -340 -881 -722 -167 -469 1 -995 407
|
||||
-1275 518 -356 1213 -220 1555 305 199 304 233 689 93 1026 -96 232 -297 445
|
||||
-527 561 -192 96 -436 136 -647 105z m221 -131 c-3 -13 -40 -241 -83 -508 -42
|
||||
-267 -80 -489 -84 -493 -7 -8 -530 775 -530 793 0 12 95 78 175 121 134 72
|
||||
272 107 429 108 97 1 98 1 93 -21z m302 -44 c20 -9 32 -18 26 -19 -5 -2 -35
|
||||
-10 -65 -19 -30 -8 -81 -31 -112 -50 -66 -39 -65 -40 -48 60 16 90 10 85 91
|
||||
63 39 -11 88 -27 108 -35z m255 -173 c128 -63 199 -178 200 -326 1 -70 -3 -89
|
||||
-31 -147 -134 -284 -529 -284 -661 0 -22 48 -27 71 -27 147 0 77 4 98 26 146
|
||||
68 144 195 222 351 216 62 -3 89 -10 142 -36z m-1278 -173 c33 -50 62 -97 66
|
||||
-104 6 -9 -8 -10 -55 -7 -72 6 -149 -8 -209 -37 -23 -11 -43 -18 -45 -17 -1 2
|
||||
19 40 45 85 47 82 116 172 131 172 5 0 35 -41 67 -92z m109 -252 c110 -52 178
|
||||
-160 179 -282 1 -62 -3 -80 -34 -140 -56 -110 -137 -160 -281 -173 l-66 -6 67
|
||||
-240 c36 -132 65 -242 63 -244 -9 -11 -150 142 -200 217 -95 142 -148 295
|
||||
-163 467 -14 174 13 270 101 349 62 56 114 74 209 75 59 1 86 -4 125 -23z
|
||||
m1516 -129 c19 -96 26 -281 10 -306 -7 -11 -89 -12 -491 -6 l-482 8 5 26 c3
|
||||
14 13 76 23 138 l18 112 57 -63 c150 -164 379 -202 583 -99 97 49 205 185 230
|
||||
288 7 26 9 28 18 12 5 -10 18 -60 29 -110z m-1154 -332 l63 -95 95 -362 c53
|
||||
-200 98 -369 101 -377 4 -12 -16 -13 -118 -8 -141 6 -249 31 -353 81 l-70 33
|
||||
-64 234 c-35 129 -65 238 -65 242 -1 4 22 16 49 25 74 26 136 65 184 117 42
|
||||
46 101 155 101 188 0 32 15 17 77 -78z m619 -99 c1 0 4 -146 7 -323 l6 -321
|
||||
-47 -20 c-62 -27 -200 -67 -207 -61 -4 4 -195 719 -195 731 0 3 434 -2 436 -6z
|
||||
m519 -7 c9 -10 -49 -162 -90 -235 -52 -93 -145 -205 -222 -269 -37 -30 -71
|
||||
-55 -75 -55 -4 0 -8 64 -8 143 0 78 -3 207 -7 285 l-6 144 201 -4 c110 -1 203
|
||||
-6 207 -9z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
{:else if i === "trakt"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 24C5.385 24 0 18.615 0 12S5.385 0 12 0s12 5.385 12 12-5.385 12-12 12zm0-22.789C6.05 1.211 1.211 6.05 1.211 12S6.05 22.79 12 22.79 22.79 17.95 22.79 12 17.95 1.211 12 1.211zm-7.11 17.32c1.756 1.92 4.294 3.113 7.11 3.113 1.439 0 2.801-.313 4.027-.876l-6.697-6.68-4.44 4.443zm14.288-.067c1.541-1.71 2.484-3.99 2.484-6.466 0-3.885-2.287-7.215-5.568-8.76l-6.089 6.076 9.164 9.15h.009zm-9.877-8.429L4.227 15.09l-.679-.68 5.337-5.336 6.23-6.225c-.978-.328-2.02-.509-3.115-.509C6.663 2.337 2.337 6.663 2.337 12c0 2.172.713 4.178 1.939 5.801l5.056-5.055.359.329 7.245 7.245c.15-.082.285-.164.42-.266L9.33 12.05l-4.854 4.855-.679-.679 5.535-5.535.359.331 8.46 8.437c.135-.1.255-.215.375-.316L9.39 10.027l-.083.015-.006-.007zm3.047 1.028l-.678-.676 4.788-4.79.679.689-4.789 4.785v-.008zm4.542-6.578l-5.52 5.52-.68-.679 5.521-5.52.679.684v-.005z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "myanimelist"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M8.273 7.247v8.423l-2.103-.003v-5.216l-2.03 2.404-1.989-2.458-.02 5.285H.001L0 7.247h2.203l1.865 2.545 2.015-2.546 2.19.001zm8.628 2.069l.025 6.335h-2.365l-.008-2.871h-2.8c.07.499.21 1.266.417 1.779.155.381.298.751.583 1.128l-1.705 1.125c-.349-.636-.622-1.337-.878-2.082a9.296 9.296 0 0 1-.507-2.179c-.085-.75-.097-1.471.107-2.212a3.908 3.908 0 0 1 1.161-1.866c.313-.293.749-.5 1.1-.687.351-.187.743-.264 1.107-.359a7.405 7.405 0 0 1 1.191-.183c.398-.034 1.107-.066 2.39-.028l.545 1.749H14.51c-.593.008-.878.001-1.341.209a2.236 2.236 0 0 0-1.278 1.92l2.663.033.038-1.81h2.309zm3.992-2.099v6.627l3.107.032-.43 1.775h-4.807V7.187l2.13.03z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "todomovies"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="100 20 600 570">
|
||||
<path d="m494.113 203.464 50.423 50.423-178.649 178.65-50.423-50.424z" />
|
||||
<path d="m234.964 300.388 50.424-50.424 130.149 130.15-50.424 50.423z" />
|
||||
<path
|
||||
d="m95.774 15.493 187.325.704c.704 0-5.634 276.761-188.733 488.029M700.705 14.789l-187.243.704c-.703 0 5.632 276.76 188.651 488.029"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "themoviedb"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M6.62 12a2.291 2.291 0 0 1 2.292-2.295h-.013A2.291 2.291 0 0 1 11.189 12a2.291 2.291 0 0 1-2.29 2.291h.013A2.291 2.291 0 0 1 6.62 12zm10.72-4.062h4.266a2.291 2.291 0 0 0 2.29-2.291 2.291 2.291 0 0 0-2.29-2.296H17.34a2.291 2.291 0 0 0-2.291 2.296 2.291 2.291 0 0 0 2.29 2.29zM2.688 20.645h8.285a2.291 2.291 0 0 0 2.291-2.292 2.291 2.291 0 0 0-2.29-2.295H2.687a2.291 2.291 0 0 0-2.291 2.295 2.291 2.291 0 0 0 2.29 2.292zm10.881-6.354h.81l1.894-4.586H15.19l-1.154 3.008h-.013l-1.135-3.008h-1.154zm4.208 0h1.011V9.705h-1.011zm2.878 0h3.235v-.93h-2.223v-.933h1.99v-.934h-1.99v-.855h2.107v-.934h-3.112zM1.31 7.941h1.01V4.247h1.31v-.895H0v.895h1.31zm3.747 0h1.011V5.959h1.958v1.984h1.011v-4.59h-1.01v1.711H6.061V3.351H5.057zm5.348 0h3.242v-.933H11.41v-.934h1.99v-.933h-1.99v-.856h2.107v-.934h-3.112zM.162 14.296h1.005v-3.52h.013l1.167 3.52h.765l1.206-3.52h.013v3.52h1.011v-4.59H3.82L2.755 12.7h-.013L1.686 9.705H.156zm14.534 6.353h1.641a3.188 3.188 0 0 0 .98-.149 2.531 2.531 0 0 0 .824-.437 2.123 2.123 0 0 0 .567-.713 2.193 2.193 0 0 0 .223-.983 2.399 2.399 0 0 0-.218-1.07 1.958 1.958 0 0 0-.586-.716 2.405 2.405 0 0 0-.873-.392 4.349 4.349 0 0 0-1.046-.13h-1.519zm1.013-3.656h.596a2.26 2.26 0 0 1 .606.08 1.514 1.514 0 0 1 .503.244 1.167 1.167 0 0 1 .34.412 1.28 1.28 0 0 1 .13.587 1.546 1.546 0 0 1-.13.658 1.127 1.127 0 0 1-.347.433 1.41 1.41 0 0 1-.518.238 2.797 2.797 0 0 1-.649.07h-.538zm4.686 3.656h1.88a2.997 2.997 0 0 0 .613-.064 1.735 1.735 0 0 0 .554-.214 1.221 1.221 0 0 0 .402-.39 1.105 1.105 0 0 0 .155-.606 1.188 1.188 0 0 0-.071-.415 1.01 1.01 0 0 0-.204-.34 1.087 1.087 0 0 0-.317-.24 1.297 1.297 0 0 0-.413-.13v-.012a1.203 1.203 0 0 0 .575-.366.962.962 0 0 0 .216-.648 1.081 1.081 0 0 0-.149-.603 1.022 1.022 0 0 0-.389-.354 1.673 1.673 0 0 0-.54-.169 4.463 4.463 0 0 0-.6-.041h-1.712zm1.011-3.734h.687a1.4 1.4 0 0 1 .24.022.748.748 0 0 1 .22.075.432.432 0 0 1 .16.147.418.418 0 0 1 .061.236.47.47 0 0 1-.055.233.433.433 0 0 1-.146.156.62.62 0 0 1-.204.084 1.058 1.058 0 0 1-.23.026h-.745zm0 1.835h.765a1.96 1.96 0 0 1 .266.02 1.015 1.015 0 0 1 .26.07.519.519 0 0 1 .204.152.406.406 0 0 1 .08.26.481.481 0 0 1-.06.253.519.519 0 0 1-.16.168.62.62 0 0 1-.217.09 1.155 1.155 0 0 1-.237.027H21.4z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "gamepad"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M483.13 245.38C461.92 149.49 430 98.31 382.65 84.33A107.13 107.13 0 00352 80c-13.71 0-25.65 3.34-38.28 6.88C298.5 91.15 281.21 96 256 96s-42.51-4.84-57.76-9.11C185.6 83.34 173.67 80 160 80a115.74 115.74 0 00-31.73 4.32c-47.1 13.92-79 65.08-100.52 161C4.61 348.54 16 413.71 59.69 428.83a56.62 56.62 0 0018.64 3.22c29.93 0 53.93-24.93 70.33-45.34 18.53-23.1 40.22-34.82 107.34-34.82 59.95 0 84.76 8.13 106.19 34.82 13.47 16.78 26.2 28.52 38.9 35.91 16.89 9.82 33.77 12 50.16 6.37 25.82-8.81 40.62-32.1 44-69.24 2.57-28.48-1.39-65.89-12.12-114.37zM208 240h-32v32a16 16 0 01-32 0v-32h-32a16 16 0 010-32h32v-32a16 16 0 0132 0v32h32a16 16 0 010 32zm84 4a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-19.95A20 20 0 01336 288zm0-88a20 20 0 1120-20 20 20 0 01-20 20zm44 44a20 20 0 1120-20 20 20 0 01-20 20z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "film"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M436 80H76a44.05 44.05 0 00-44 44v264a44.05 44.05 0 0044 44h360a44.05 44.05 0 0044-44V124a44.05 44.05 0 00-44-44zM112 388a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12H76a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm241.68 124H158.32a16 16 0 010-32h195.36a16 16 0 110 32zM448 388a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12zm0-80a12 12 0 01-12 12h-24a12 12 0 01-12-12v-24a12 12 0 0112-12h24a12 12 0 0112 12z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "tv"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M447.86 384H64.14A48.2 48.2 0 0116 335.86V128.14A48.2 48.2 0 0164.14 80h383.72A48.2 48.2 0 01496 128.14v207.72A48.2 48.2 0 01447.86 384z"
|
||||
/>
|
||||
<path
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-miterlimit="10"
|
||||
stroke-width="32"
|
||||
d="M128 416h256"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "pin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
|
||||
<path
|
||||
d="m640-480 80 80v80H520v240l-40 40-40-40v-240H240v-80l80-80v-280h-40v-80h400v80h-40v280Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "unpin"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 -960 960 960">
|
||||
<path
|
||||
d="M680-840v80h-40v327L313-760l-33-33v-47h400ZM480-40l-40-40v-240H240v-80l80-80v-46L56-792l56-56 736 736-58 56-264-264h-6v240l-40 40Z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "sparkles"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M208 512a24.84 24.84 0 01-23.34-16l-39.84-103.6a16.06 16.06 0 00-9.19-9.19L32 343.34a25 25 0 010-46.68l103.6-39.84a16.06 16.06 0 009.19-9.19L184.66 144a25 25 0 0146.68 0l39.84 103.6a16.06 16.06 0 009.19 9.19l103 39.63a25.49 25.49 0 0116.63 24.1 24.82 24.82 0 01-16 22.82l-103.6 39.84a16.06 16.06 0 00-9.19 9.19L231.34 496A24.84 24.84 0 01208 512zm66.85-254.84zM88 176a14.67 14.67 0 01-13.69-9.4l-16.86-43.84a7.28 7.28 0 00-4.21-4.21L9.4 101.69a14.67 14.67 0 010-27.38l43.84-16.86a7.31 7.31 0 004.21-4.21L74.16 9.79A15 15 0 0186.23.11a14.67 14.67 0 0115.46 9.29l16.86 43.84a7.31 7.31 0 004.21 4.21l43.84 16.86a14.67 14.67 0 010 27.38l-43.84 16.86a7.28 7.28 0 00-4.21 4.21l-16.86 43.84A14.67 14.67 0 0188 176zM400 256a16 16 0 01-14.93-10.26l-22.84-59.37a8 8 0 00-4.6-4.6l-59.37-22.84a16 16 0 010-29.86l59.37-22.84a8 8 0 004.6-4.6l22.67-58.95a16.45 16.45 0 0113.17-10.57 16 16 0 0116.86 10.15l22.84 59.37a8 8 0 004.6 4.6l59.37 22.84a16 16 0 010 29.86l-59.37 22.84a8 8 0 00-4.6 4.6l-22.84 59.37A16 16 0 01400 256z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "tag"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
d="M467 45.2A44.45 44.45 0 00435.29 32H312.36a30.63 30.63 0 00-21.52 8.89L45.09 286.59a44.82 44.82 0 000 63.32l117 117a44.83 44.83 0 0063.34 0l245.65-245.6A30.6 30.6 0 00480 199.8v-123a44.24 44.24 0 00-13-31.6zM384 160a32 32 0 1132-32 32 32 0 01-32 32z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if i === "ticket"}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={wh} height={wh} viewBox="0 0 512 512">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M490.18 181.4l-44.13-44.13a20 20 0 00-27-1 30.81 30.81 0 01-41.68-1.6 30.81 30.81 0 01-1.6-41.67 20 20 0 00-1-27L330.6 21.82a19.91 19.91 0 00-28.13 0l-70.35 70.34a39.87 39.87 0 00-9.57 15.5 7.71 7.71 0 01-4.83 4.83 39.78 39.78 0 00-15.5 9.58l-180.4 180.4a19.91 19.91 0 000 28.13L66 374.73a20 20 0 0027 1 30.69 30.69 0 0143.28 43.28 20 20 0 001 27l44.13 44.13a19.91 19.91 0 0028.13 0l180.4-180.4a39.82 39.82 0 009.58-15.49 7.69 7.69 0 014.84-4.84 39.84 39.84 0 0015.49-9.57l70.34-70.35a19.91 19.91 0 00-.01-28.09zm-228.37-29.65a16 16 0 01-22.63 0l-11.51-11.51a16 16 0 0122.63-22.62l11.51 11.5a16 16 0 010 22.63zm44 44a16 16 0 01-22.62 0l-11-11a16 16 0 1122.63-22.63l11 11a16 16 0 01.01 22.66zm44 44a16 16 0 01-22.63 0l-11-11a16 16 0 0122.63-22.62l11 11a16 16 0 01.05 22.67zm44.43 44.54a16 16 0 01-22.63 0l-11.44-11.5a16 16 0 1122.68-22.57l11.45 11.49a16 16 0 01-.01 22.63z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import { JobStatus, type GetJobResponse, type JobCreatedResponse } from "@/types";
|
||||
import axios from "axios";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { watchedList } from "@/store";
|
||||
|
||||
export let modalTitle: string;
|
||||
// Promise that returns the job id to watch.
|
||||
export let getJobId: () => Promise<{ jobId: string } | undefined>;
|
||||
export let onClose: () => void;
|
||||
export let messages: {
|
||||
starting: string;
|
||||
};
|
||||
|
||||
let step: "starting" | "errored" | "job-running" | "done" | "modal-closing" = "starting";
|
||||
let jobId: string | undefined;
|
||||
let currentTask: string | undefined;
|
||||
let latestJobStatus: GetJobResponse | undefined;
|
||||
let jobFailError: string | undefined;
|
||||
|
||||
async function startSync() {
|
||||
try {
|
||||
const r = await getJobId();
|
||||
console.log("startSync: Response:", r);
|
||||
if (!r?.jobId) {
|
||||
step = "errored";
|
||||
console.error("startSync: No jobId returned!");
|
||||
return;
|
||||
}
|
||||
jobId = r.jobId;
|
||||
step = "job-running";
|
||||
startJobWatcher();
|
||||
} catch (err: any) {
|
||||
console.error("startSync failed!", err);
|
||||
step = "errored";
|
||||
jobFailError = err?.response ? err?.response?.data?.error : String(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function startJobWatcher() {
|
||||
if (!jobId) {
|
||||
console.error("startJobWatcher: No Job Id");
|
||||
notify({ text: "Unable to start job watcher, no job id.", type: "error" });
|
||||
return;
|
||||
}
|
||||
console.log("startJobWatcher: Starting..");
|
||||
let seqfailedJobReqs = 0;
|
||||
while (step === "job-running") {
|
||||
try {
|
||||
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
|
||||
console.log("jobWatcher: Got job data:", r.data);
|
||||
latestJobStatus = r.data;
|
||||
currentTask = r.data?.currentTask;
|
||||
if (r.data?.status === JobStatus.DONE) {
|
||||
step = "done";
|
||||
} else if (r.data?.status === JobStatus.CANCELLED) {
|
||||
step = "errored";
|
||||
}
|
||||
// If we get here without erroring, we can reset it to 0.
|
||||
seqfailedJobReqs = 0;
|
||||
} catch (err) {
|
||||
console.error("jobWatcher: Get job request failed!", seqfailedJobReqs, err);
|
||||
seqfailedJobReqs++;
|
||||
}
|
||||
if (seqfailedJobReqs >= 10) {
|
||||
console.error("jobWatcher: Failed 10 times in a row!");
|
||||
notify({
|
||||
text: "Status checker has failed 10 times in a row!",
|
||||
type: "error",
|
||||
time: 30000
|
||||
});
|
||||
step = "errored";
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (step !== "modal-closing") {
|
||||
// Update our watched list
|
||||
const nid = notify({ text: "Fetching updated watched list.", type: "loading" });
|
||||
try {
|
||||
const w = await axios.get("/watched");
|
||||
if (w?.data?.length > 0) {
|
||||
watchedList.update((wl) => (wl = w.data));
|
||||
}
|
||||
notify({ id: nid, text: "Fetched updated watched list.", type: "success" });
|
||||
} catch (err) {
|
||||
console.error("jobWatcher: Getting updated watched list failed!", err);
|
||||
notify({ id: nid, text: "Getting updated watched list failed!", type: "error" });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function modalClose() {
|
||||
if (step === "job-running") {
|
||||
notify({
|
||||
text: "Sync will continue in the background.. please refresh the page periodically to view your updated list or come back later.",
|
||||
time: 10000
|
||||
});
|
||||
}
|
||||
step = "modal-closing";
|
||||
onClose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
startSync();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
step = "starting";
|
||||
jobId = undefined;
|
||||
currentTask = undefined;
|
||||
latestJobStatus = undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<Modal title={modalTitle} maxWidth="700px" onClose={modalClose}>
|
||||
<div class="ctr">
|
||||
{#if step === "done"}
|
||||
<Icon i="check" wh={60} />
|
||||
{:else if step === "errored"}
|
||||
<Icon i="close" wh={70} />
|
||||
{:else}
|
||||
<Spinner />
|
||||
{/if}
|
||||
<div>
|
||||
{#if step === "starting"}
|
||||
<h4 class="norm">Starting</h4>
|
||||
<span>{messages.starting}</span>
|
||||
{:else if step === "job-running"}
|
||||
<h4 class="norm">Running</h4>
|
||||
{#if currentTask}
|
||||
<span>{currentTask}</span>
|
||||
{/if}
|
||||
{:else if step === "done"}
|
||||
{#if !latestJobStatus?.errors || latestJobStatus?.errors?.length <= 0}
|
||||
<h4 class="norm">Finished</h4>
|
||||
<span>We have finished. Looks like there were no errors!</span>
|
||||
{:else}
|
||||
<h4 class="norm">
|
||||
Finished With {latestJobStatus?.errors?.length} Error{latestJobStatus?.errors
|
||||
?.length === 1
|
||||
? ""
|
||||
: "s"}
|
||||
</h4>
|
||||
<span>Job finished, but with errors:</span>
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else if step === "errored"}
|
||||
<h4 class="norm">We Errored!</h4>
|
||||
{#if jobFailError}
|
||||
<span>{jobFailError}</span>
|
||||
{:else}
|
||||
<span>We errored before starting the job or the job was cancelled.</span>
|
||||
{/if}
|
||||
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{:else}
|
||||
<h4 class="norm">Unknown State!</h4>
|
||||
<span>We're not sure of the current job status.</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.ctr {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
gap: 20px;
|
||||
justify-content: start;
|
||||
align-items: start;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
margin-left: 15px;
|
||||
|
||||
& > div:last-of-type {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
|
||||
& > span {
|
||||
font-style: italic;
|
||||
|
||||
&::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
& > ul {
|
||||
padding-left: 25px;
|
||||
|
||||
li::first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@
|
||||
export let desc: string | undefined = undefined;
|
||||
export let onClose: (() => void) | undefined = undefined;
|
||||
export let maxWidth = "1000px";
|
||||
export let error: string | undefined = undefined; // TODO This property is new, mimics what we do with other modals by showing an error at top.. we could migrate to use this in other places.
|
||||
</script>
|
||||
|
||||
<div class="backdrop"></div>
|
||||
@@ -17,6 +18,9 @@
|
||||
{#if desc}
|
||||
<h5 class="norm">{desc}</h5>
|
||||
{/if}
|
||||
{#if error}
|
||||
<span class="error">{error}</span>
|
||||
{/if}
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -75,6 +79,19 @@
|
||||
@media screen and (max-width: 680px) {
|
||||
margin: 20px;
|
||||
}
|
||||
|
||||
.error {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
background-color: rgb(221, 48, 48);
|
||||
text-transform: capitalize;
|
||||
color: white;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
|
||||
export let rating: number | undefined;
|
||||
export let onChange: (newRating: number) => void;
|
||||
|
||||
let hoveredRating: number | undefined;
|
||||
let shownRating: number | undefined;
|
||||
let ratingContainer: HTMLDivElement;
|
||||
let ratingText: HTMLSpanElement;
|
||||
|
||||
const ratingDesc = [
|
||||
"Apalling",
|
||||
"Horrible",
|
||||
"Very Bad",
|
||||
"Bad",
|
||||
"Average",
|
||||
"Fine",
|
||||
"Good",
|
||||
"Very Good",
|
||||
"Great",
|
||||
"Masterpiece"
|
||||
];
|
||||
|
||||
function handleStarClick(r: number) {
|
||||
if (r === rating) return;
|
||||
onChange(r);
|
||||
}
|
||||
|
||||
$: {
|
||||
if (hoveredRating !== undefined) shownRating = hoveredRating;
|
||||
else if (rating !== undefined) {
|
||||
shownRating = rating;
|
||||
resetRatingText();
|
||||
} else shownRating = undefined;
|
||||
}
|
||||
|
||||
function resetRatingText() {
|
||||
if (!ratingText) {
|
||||
console.warn("resetRatingText: ratingText is not defined yet.");
|
||||
return;
|
||||
}
|
||||
if (typeof rating === "number" && rating > 0) {
|
||||
ratingText.innerText = ratingDesc[rating - 1];
|
||||
} else {
|
||||
ratingText.innerText = "Select Your Rating";
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarHover(
|
||||
ev: MouseEvent & {
|
||||
currentTarget: EventTarget & HTMLButtonElement;
|
||||
},
|
||||
r: number
|
||||
) {
|
||||
hoveredRating = r;
|
||||
// We set innerText instead of letting svelte update dom for us
|
||||
// since we need the new width of span right now.
|
||||
ratingText.innerText = ratingDesc[r - 1];
|
||||
const start = ratingContainer?.getBoundingClientRect()?.x;
|
||||
const starl = ev?.currentTarget?.getBoundingClientRect()?.left;
|
||||
const rb = ratingText?.getBoundingClientRect();
|
||||
ratingText.style.left = `${starl - start - rb.width / 2 + 11.5}px`;
|
||||
ratingText.style.transform = "unset";
|
||||
}
|
||||
|
||||
function handleStarHoverEnd() {
|
||||
hoveredRating = undefined;
|
||||
ratingText.style.left = "50%";
|
||||
ratingText.style.transform = "translateX(-50%)";
|
||||
resetRatingText();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
resetRatingText();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="rating-container" bind:this={ratingContainer}>
|
||||
<span bind:this={ratingText}></span>
|
||||
<div class="rating">
|
||||
{#each [10, 9, 8, 7, 6, 5, 4, 3, 2, 1] as v}
|
||||
<button
|
||||
class="plain{shownRating === v ? ' lit' : ''}"
|
||||
on:click={() => handleStarClick(v)}
|
||||
on:mouseenter={(ev) => handleStarHover(ev, v)}
|
||||
on:mouseleave={() => handleStarHoverEnd()}
|
||||
>
|
||||
*
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.rating-container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
overflow: visible;
|
||||
|
||||
& > span {
|
||||
position: relative;
|
||||
transition:
|
||||
left 100ms ease-in,
|
||||
transform 100ms ease-in;
|
||||
max-width: max-content;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
flex-flow: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color;
|
||||
-webkit-text-stroke: 1.5px $text-color;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
margin: 10px 0 10px 0;
|
||||
padding: 1px;
|
||||
|
||||
button {
|
||||
font-size: 55px;
|
||||
font-family: "Rampart One";
|
||||
letter-spacing: 10px;
|
||||
line-height: 52px;
|
||||
height: 38px;
|
||||
|
||||
&:global(.lit),
|
||||
&:global(.lit ~ button) {
|
||||
color: gold;
|
||||
-webkit-text-stroke: 1.5px gold;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 450px) {
|
||||
font-size: 50px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
font-size: 45px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import DropDown from "./DropDown.svelte";
|
||||
import type { DropDownItem, TMDBRegions } from "@/types";
|
||||
import Error from "./Error.svelte";
|
||||
|
||||
export let selectedCountry: string = "US";
|
||||
export let disabled = false;
|
||||
export let onChange: (country: string) => void;
|
||||
|
||||
let mappedCountries: DropDownItem[];
|
||||
|
||||
async function getCountries() {
|
||||
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
|
||||
mappedCountries = c.results
|
||||
.map((cc) => {
|
||||
return {
|
||||
id: cc.iso_3166_1,
|
||||
value: cc.english_name
|
||||
} as DropDownItem;
|
||||
})
|
||||
.sort((a, b) => a.value.localeCompare(b.value));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await getCountries() then}
|
||||
<DropDown
|
||||
placeholder="Select a country"
|
||||
bind:active={selectedCountry}
|
||||
options={mappedCountries}
|
||||
onChange={() => onChange(selectedCountry)}
|
||||
isDropDownItem={true}
|
||||
{disabled}
|
||||
/>
|
||||
{:catch err}
|
||||
<Error error={err} pretty="Failed to load countries!" />
|
||||
{/await}
|
||||
@@ -18,17 +18,44 @@
|
||||
|
||||
export let tvId: number;
|
||||
export let seasons: TMDBShowSeason[];
|
||||
export let watchedItem: Watched; // Watched list item id
|
||||
export let watchedItem: Watched | undefined;
|
||||
|
||||
let activeSeason = 1;
|
||||
let activeSeason =
|
||||
typeof watchedItem?.lastViewedSeason === "number" ? watchedItem?.lastViewedSeason : 1;
|
||||
let seasonDetailsReq: Promise<TMDBSeasonDetails>;
|
||||
|
||||
async function sdr(seasonNum: number) {
|
||||
return (await axios.get(`/content/tv/${tvId}/season/${seasonNum}`)).data as TMDBSeasonDetails;
|
||||
const resp = await axios.get(`/content/tv/${tvId}/season/${seasonNum}`, {
|
||||
params: {
|
||||
watchedId: watchedItem?.id
|
||||
}
|
||||
});
|
||||
try {
|
||||
if (watchedItem?.id) {
|
||||
// If we sent a watched id, expect a 'watcharr-lastviewedseason-saved' header in the response.
|
||||
const hVal = resp.headers["watcharr-lastviewedseason-saved"];
|
||||
if (hVal) {
|
||||
watchedItem.lastViewedSeason = seasonNum;
|
||||
watchedList.update((w) => w);
|
||||
} else {
|
||||
console.error(
|
||||
"SeasonList: sdr: No header in response indicating that the lastviewedseason was saved."
|
||||
);
|
||||
notify({ type: "error", text: "Failed when saving last viewed season" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("SeasonList: sdr: Failed to process lastviewedseason-saved header.", err);
|
||||
}
|
||||
return resp.data as TMDBSeasonDetails;
|
||||
}
|
||||
|
||||
// Add/update watched season
|
||||
function updateWatchedSeason(seasonNumber: number, status?: WatchedStatus, rating?: number) {
|
||||
if (!watchedItem) {
|
||||
console.error("updateWatchedSeason: No watched item.");
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedSeasonAddResponse>(`/watched/season`, {
|
||||
@@ -66,6 +93,10 @@
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE", seasonNumber: number) {
|
||||
if (!watchedItem) {
|
||||
console.error("handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedSeasons?.find((s) => s.seasonNumber === seasonNumber);
|
||||
if (!ws) {
|
||||
@@ -148,7 +179,7 @@
|
||||
(s) => s.seasonNumber === season.season_number
|
||||
)}
|
||||
{#if ws}
|
||||
<div class="rating" style={ws?.rating ? "width: 65px" : "width: 45px"}>
|
||||
<div class="rating">
|
||||
<PosterRating
|
||||
rating={ws?.rating}
|
||||
btnTooltip="Season Rating"
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
$: settings = $userSettings;
|
||||
|
||||
export let ep: TMDBSeasonDetailsEpisode;
|
||||
export let watchedItem: Watched;
|
||||
export let watchedItem: Watched | undefined;
|
||||
|
||||
let isHidden = false;
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
}
|
||||
|
||||
function updateWatchedEpisode(status?: WatchedStatus, rating?: number) {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: updateWatchedEpisode: No watched item.");
|
||||
return;
|
||||
}
|
||||
const nid = notify({ text: `Saving`, type: "loading" });
|
||||
axios
|
||||
.post<WatchedEpisodeAddResponse>(`/watched/episode`, {
|
||||
@@ -53,6 +57,47 @@
|
||||
} else {
|
||||
wEntry.activity = [r.data.addedActivity];
|
||||
}
|
||||
try {
|
||||
const epHookResp = r?.data?.episodeStatusChangedHookResponse;
|
||||
if (epHookResp && Object.keys(epHookResp).length > 0) {
|
||||
if (epHookResp.errors && epHookResp.errors.length > 0) {
|
||||
console.error(
|
||||
"episodeStatusChangedHookResponse contained errors! All possible automations may not have been completed.",
|
||||
epHookResp.errors
|
||||
);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Some automations have failed, check console for more info."
|
||||
});
|
||||
}
|
||||
if (epHookResp.addedActivities && epHookResp.addedActivities.length > 0) {
|
||||
wEntry.activity.push(...epHookResp.addedActivities);
|
||||
}
|
||||
if (epHookResp.watchedSeason) {
|
||||
if (!wEntry.watchedSeasons) {
|
||||
wEntry.watchedSeasons = [epHookResp.watchedSeason];
|
||||
} else {
|
||||
const watchedSeasonIdx = wEntry.watchedSeasons.findIndex(
|
||||
(s) => s.id === epHookResp.watchedSeason?.id
|
||||
);
|
||||
if (watchedSeasonIdx === -1) {
|
||||
wEntry.watchedSeasons.push(epHookResp.watchedSeason);
|
||||
} else {
|
||||
wEntry.watchedSeasons[watchedSeasonIdx] = epHookResp.watchedSeason;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (epHookResp.newShowStatus) {
|
||||
wEntry.status = epHookResp.newShowStatus;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to process episodeStatusChangedHookResponse", err);
|
||||
notify({
|
||||
type: "error",
|
||||
text: "Failed to process automation response, check console for more info."
|
||||
});
|
||||
}
|
||||
watchedList.update((w) => w);
|
||||
notify({ id: nid, text: `Saved!`, type: "success" });
|
||||
}
|
||||
@@ -64,6 +109,10 @@
|
||||
}
|
||||
|
||||
function handleStatusClick(type: WatchedStatus | "DELETE") {
|
||||
if (!watchedItem) {
|
||||
console.error("SeasonListEpisode: handleStatusClick: No watched item.");
|
||||
return;
|
||||
}
|
||||
if (type === "DELETE") {
|
||||
const ws = watchedItem.watchedEpisodes?.find(
|
||||
(s) => s.seasonNumber === ep.season_number && s.episodeNumber === ep.episode_number
|
||||
@@ -129,7 +178,7 @@
|
||||
<span class="episode-name">{ep.name}</span>
|
||||
{#if ep.runtime}
|
||||
<span class="episode-runtime" title="This episode has a runtime of {ep.runtime} minutes."
|
||||
>{ep.runtime}m</span
|
||||
>{ep.runtime} min</span
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
width: 16px;
|
||||
height: 13px;
|
||||
height: 12px;
|
||||
border: 2px solid #000;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
|
||||
button {
|
||||
font-size: 10px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,11 +3,18 @@
|
||||
import Icon from "@/lib/Icon.svelte";
|
||||
import Poster from "@/lib/poster/Poster.svelte";
|
||||
import PosterList from "@/lib/poster/PosterList.svelte";
|
||||
import { activeFilters, activeSort, serverFeatures, userSettings } from "@/store";
|
||||
import type { Watched, WatchedEpisode, WatchedSeason } from "@/types";
|
||||
import {
|
||||
activeFilters,
|
||||
activeSort,
|
||||
clearActiveFilters,
|
||||
serverFeatures,
|
||||
userSettings
|
||||
} from "@/store";
|
||||
import type { Watched } from "@/types";
|
||||
import GamePoster from "./poster/GamePoster.svelte";
|
||||
import { get } from "svelte/store";
|
||||
import { seasonAndEpToReadable } from "./util/helpers";
|
||||
import { getLatestWatchedInTv } from "./util/helpers";
|
||||
import { notify } from "./util/notify";
|
||||
|
||||
export let list: Watched[];
|
||||
export let isPublicList: boolean = false;
|
||||
@@ -57,140 +64,104 @@
|
||||
$: (watched, filters, sort), filt();
|
||||
|
||||
function filt() {
|
||||
// Set watched to list and sort it.
|
||||
watched = list.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
try {
|
||||
// Set watched to list and sort it.
|
||||
watched = list
|
||||
.sort((a, b) => {
|
||||
if (sort[0] === "DATEADDED" && sort[1] === "UP") {
|
||||
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
|
||||
} else if (sort[0] === "ALPHA") {
|
||||
const atitle = a.content ? a.content.title : a.game ? a.game.name : "";
|
||||
const btitle = b.content ? b.content.title : b.game ? b.game.name : "";
|
||||
if (sort[1] === "UP") {
|
||||
return atitle.localeCompare(btitle);
|
||||
} else if (sort[1] === "DOWN") {
|
||||
return btitle.localeCompare(atitle);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "LASTFIN") {
|
||||
const aLastFinishActivity = a.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt)
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
|
||||
);
|
||||
const bLastFinishActivity = b.activity
|
||||
?.sort(
|
||||
(aa, bb) =>
|
||||
Date.parse(bb.customDate ?? bb.updatedAt) -
|
||||
Date.parse(aa.customDate ?? aa.updatedAt)
|
||||
)
|
||||
?.find(
|
||||
(aa) =>
|
||||
(aa.type === "STATUS_CHANGED" && aa.data === "FINISHED") ||
|
||||
(aa.type === "ADDED_WATCHED" && aa.data?.includes("FINISHED"))
|
||||
);
|
||||
if (!aLastFinishActivity) return 1;
|
||||
if (!bLastFinishActivity) return -1;
|
||||
const alfaDate = aLastFinishActivity.customDate ?? aLastFinishActivity.updatedAt;
|
||||
const blfaDate = bLastFinishActivity.customDate ?? bLastFinishActivity.updatedAt;
|
||||
if (sort[1] === "UP") return Date.parse(alfaDate) - Date.parse(blfaDate);
|
||||
else if (sort[1] === "DOWN") return Date.parse(blfaDate) - Date.parse(alfaDate);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.pinned && !b.pinned) return -1;
|
||||
if (!a.pinned && b.pinned) return 1;
|
||||
return 0;
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
} else if (sort[0] === "LASTCHANGED") {
|
||||
if (sort[1] === "UP") return Date.parse(a.updatedAt) - Date.parse(b.updatedAt);
|
||||
else if (sort[1] === "DOWN") return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
||||
} else if (sort[0] === "RATING") {
|
||||
if (sort[1] === "UP") return (a.rating ?? 0) - (b.rating ?? 0);
|
||||
else if (sort[1] === "DOWN") return (b.rating ?? 0) - (a.rating ?? 0);
|
||||
}
|
||||
// default DATEADDED DOWN
|
||||
return Date.parse(b.createdAt) - Date.parse(a.createdAt);
|
||||
});
|
||||
// If games type filter enabled, but games disabled on server, make sure we remove it from active filters.
|
||||
if (!features.games) {
|
||||
const af = get(activeFilters);
|
||||
af.type = af.type?.filter((a) => a !== "game");
|
||||
filters.type = filters.type.filter((f) => f !== "game");
|
||||
}
|
||||
// Now apply filters to watch list.
|
||||
if (filters.status.length > 0 && filters.type.length > 0) {
|
||||
// If status and type filters applied, combine both.
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
(filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter(
|
||||
(w) =>
|
||||
filters.status.includes(w.status?.toLowerCase()) &&
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
}
|
||||
} else if (filters.type.length > 0) {
|
||||
// Only filter type
|
||||
watched = watched.filter((w) =>
|
||||
filters.type.includes(w.content ? w.content.type : w.game ? "game" : "")
|
||||
);
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get biggest season watching or biggest season watched.
|
||||
// This could probably be simpler but -_-
|
||||
function getLatestWatchedInTv(
|
||||
ws: WatchedSeason[] | undefined,
|
||||
we: WatchedEpisode[] | undefined
|
||||
): string {
|
||||
if ((!ws || ws.length <= 0) && (!we || we.length <= 0)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
let biggestSeasonWatched = -1;
|
||||
let biggestSeasonWatching = -1;
|
||||
if (ws && ws.length > 0) {
|
||||
for (let i = 0; i < ws.length; i++) {
|
||||
const s = ws[i];
|
||||
if (s.status === "WATCHING") {
|
||||
if (s.seasonNumber > biggestSeasonWatching) {
|
||||
biggestSeasonWatching = s.seasonNumber;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (s.seasonNumber > biggestSeasonWatched) {
|
||||
biggestSeasonWatched = s.seasonNumber;
|
||||
}
|
||||
} else if (filters.status.length > 0) {
|
||||
// Only filter status
|
||||
if (settings?.includePreviouslyWatched && filters.status.includes("finished")) {
|
||||
watched = watched.filter(
|
||||
(w) => filters.status.includes(w.status?.toLowerCase()) || contentWatchedPreviously(w)
|
||||
);
|
||||
} else {
|
||||
watched = watched.filter((w) => filters.status.includes(w.status?.toLowerCase()));
|
||||
}
|
||||
}
|
||||
}
|
||||
const season = biggestSeasonWatching >= 0 ? biggestSeasonWatching : biggestSeasonWatched;
|
||||
|
||||
// Look for biggest watched/watching episode in season if any.
|
||||
// Does same thing as above.
|
||||
let episode: WatchedEpisode | undefined;
|
||||
if (we && we.length > 0) {
|
||||
let biggestEpisodeWatched: WatchedEpisode | undefined;
|
||||
let biggestEpisodeWatching: WatchedEpisode | undefined;
|
||||
for (let i = 0; i < we.length; i++) {
|
||||
const s = we[i];
|
||||
if (season >= 0 && s.seasonNumber !== season) continue;
|
||||
if (s.status === "WATCHING") {
|
||||
if (!biggestEpisodeWatching) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatching.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatching.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatching = s;
|
||||
}
|
||||
} else if (s.status === "FINISHED") {
|
||||
if (!biggestEpisodeWatched) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
if (
|
||||
s.episodeNumber > biggestEpisodeWatched.episodeNumber ||
|
||||
s.seasonNumber > biggestEpisodeWatched.seasonNumber
|
||||
) {
|
||||
biggestEpisodeWatched = s;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (biggestEpisodeWatched || biggestEpisodeWatching) {
|
||||
episode =
|
||||
biggestEpisodeWatching !== undefined ? biggestEpisodeWatching : biggestEpisodeWatched;
|
||||
}
|
||||
}
|
||||
|
||||
if (season >= 0 && episode) {
|
||||
return seasonAndEpToReadable(season, episode.episodeNumber);
|
||||
} else if (season >= 0) {
|
||||
return `Season ${season}`;
|
||||
} else if (episode) {
|
||||
return seasonAndEpToReadable(episode.seasonNumber, episode.episodeNumber);
|
||||
} else {
|
||||
return "";
|
||||
} catch (err) {
|
||||
console.error("filt: Failed to filter/sort current list!", err);
|
||||
notify({ text: "Failed to filter/sort list!", type: "error", time: 6000 });
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -216,6 +187,8 @@
|
||||
dateAdded: w.createdAt,
|
||||
dateModified: w.updatedAt
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
/>
|
||||
{:else if w.content}
|
||||
<Poster
|
||||
@@ -237,19 +210,30 @@
|
||||
dateModified: w.updatedAt,
|
||||
lastWatched: getLatestWatchedInTv(w.watchedSeasons, w.watchedEpisodes)
|
||||
}}
|
||||
fluidSize={true}
|
||||
pinned={w.pinned}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="empty-list">
|
||||
<Icon i="reel" wh={80} />
|
||||
{#if isPublicList}
|
||||
<h2 class="norm">This watched list is empty!</h2>
|
||||
<h4 class="norm">Come back later to see if they have added anything.</h4>
|
||||
{#if list?.length > 0}
|
||||
<!-- `watched` (filtered list) is empty, but `list` (unfiltered) isn't,
|
||||
so we should let the user know why there is nothing to show. -->
|
||||
<Icon i="filter-circle" wh={80} />
|
||||
<h2 class="norm">Filters are hiding all results!</h2>
|
||||
<h4 class="norm">Try changing or removing your active filters.</h4>
|
||||
<button on:click={() => clearActiveFilters()}>Clear Filters</button>
|
||||
{:else}
|
||||
<h2 class="norm">Your watched list is empty!</h2>
|
||||
<h4 class="norm">Try searching for something you would like to add.</h4>
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
<Icon i="reel" wh={80} />
|
||||
{#if isPublicList}
|
||||
<h2 class="norm">This watched list is empty!</h2>
|
||||
<h4 class="norm">Come back later to see if they have added anything.</h4>
|
||||
{:else}
|
||||
<h2 class="norm">Your watched list is empty!</h2>
|
||||
<h4 class="norm">Try searching for something you would like to add.</h4>
|
||||
<button on:click={() => goto("/import")}>Import</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface StayInViewOptions {
|
||||
/**
|
||||
* If the `node` contains an element (like an arrow for menus),
|
||||
* that should be shifted over to account for any shifting
|
||||
* of the `node` itself, then pass a selector for it here.
|
||||
*/
|
||||
elToShiftSelector?: string;
|
||||
}
|
||||
|
||||
export default function stayInView(node: HTMLElement, opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Initial opts:", opts);
|
||||
let { elToShiftSelector } = opts;
|
||||
let viewDeb: ReturnType<typeof setTimeout>;
|
||||
|
||||
/**
|
||||
* Move element to in view, if it isn't.
|
||||
*
|
||||
* Currently only supports moving `node` back into view if oob
|
||||
* on the left. May need to support other sides and/or resizing
|
||||
* when `node` still wont fit after shifting it in the future.
|
||||
*/
|
||||
const getInView = () => {
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const brect = document.body.getBoundingClientRect();
|
||||
console.debug("stayInView->getInView: Called.", nrect, brect);
|
||||
if (nrect.x <= brect.x) {
|
||||
const diff = nrect.x - brect.x + 10;
|
||||
console.debug(
|
||||
"stayInView->getInView: Node is out of bounds on the left, shifting forwards to:",
|
||||
diff
|
||||
);
|
||||
node.style.left = `${diff}px`;
|
||||
if (elToShiftSelector) {
|
||||
const elToShift = node.querySelector(elToShiftSelector) as HTMLElement;
|
||||
if (elToShift) {
|
||||
console.debug("stayInView->getInView: Shifting elToShift.");
|
||||
const nrectNew = node.getBoundingClientRect();
|
||||
const arrowDiff = nrectNew.left - nrect.left;
|
||||
elToShift.style.left = `${elToShift.offsetLeft - arrowDiff}px`;
|
||||
} else {
|
||||
console.warn("elToShift not found.", elToShiftSelector);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getInViewDeb = () => {
|
||||
clearTimeout(viewDeb);
|
||||
viewDeb = setTimeout(getInView, 200);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", getInViewDeb);
|
||||
getInView();
|
||||
|
||||
return {
|
||||
update(opts: StayInViewOptions) {
|
||||
console.debug("stayInView: Opts updated", opts);
|
||||
elToShiftSelector = opts.elToShiftSelector;
|
||||
getInView();
|
||||
},
|
||||
destroy() {
|
||||
window.removeEventListener("resize", getInViewDeb);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -13,9 +13,10 @@ export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
|
||||
const show = () => {
|
||||
if (!condition) return;
|
||||
const trimmedText = text?.trim();
|
||||
if (!condition || !trimmedText) return;
|
||||
if (tooltip) {
|
||||
tooltip.innerHTML = text;
|
||||
tooltip.innerHTML = trimmedText;
|
||||
const nrect = node.getBoundingClientRect();
|
||||
const trect = tooltip.getBoundingClientRect();
|
||||
nrect.y += window.scrollY; // Add scrollY to node dom rect so tooltip shows correcting when page is scrolled down
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { notify } from "../util/notify";
|
||||
|
||||
export let contentTitle: string;
|
||||
export let thoughts: string;
|
||||
export let onChange: (newThoughts: string) => Promise<boolean>;
|
||||
|
||||
let modalOpen = false;
|
||||
let textarea: HTMLTextAreaElement | undefined;
|
||||
$: thoughtsToDisplay = thoughts ? thoughts : `Set thoughts on ${contentTitle}`;
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
textarea.style.height = "";
|
||||
textarea.style.height = textarea.scrollHeight + "px";
|
||||
}
|
||||
|
||||
$: textarea, resizeTextarea();
|
||||
</script>
|
||||
|
||||
<button
|
||||
class={`plain thoughts${thoughtsToDisplay?.length > 100 ? " long" : ""}${thoughts ? "" : " placeholdered"}`}
|
||||
on:click={() => {
|
||||
modalOpen = !modalOpen;
|
||||
if (modalOpen) {
|
||||
resizeTextarea();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<i><Icon i="pencil" wh={24} /></i>
|
||||
<p>{thoughtsToDisplay}</p>
|
||||
</button>
|
||||
|
||||
{#if modalOpen}
|
||||
<Modal
|
||||
title="Your Thoughts"
|
||||
desc="View or modify your thoughts on {contentTitle}"
|
||||
onClose={async () => {
|
||||
if (!textarea) {
|
||||
notify({
|
||||
text: "Failed to find the text box! Please copy your changes to avoid losing them and try again!"
|
||||
});
|
||||
return;
|
||||
}
|
||||
// If thoughts weren't changed or changes saved successfully.
|
||||
if (thoughts === textarea.value || (await onChange(textarea.value))) {
|
||||
modalOpen = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<textarea
|
||||
name="Thoughts"
|
||||
rows="3"
|
||||
placeholder={`My thoughts on ${contentTitle}`}
|
||||
value={thoughts}
|
||||
bind:this={textarea}
|
||||
on:input={resizeTextarea}
|
||||
/>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
button.thoughts {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: start;
|
||||
padding: 7px 10px;
|
||||
border: 2px solid $text-color;
|
||||
border-radius: 5px;
|
||||
max-height: 100px;
|
||||
opacity: 0.5;
|
||||
transition: opacity 150ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 4px;
|
||||
right: 5px;
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
transition:
|
||||
opacity 150ms ease-in-out,
|
||||
transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
&:hover i {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.placeholdered {
|
||||
padding: 12px 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
max-height: 90px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&.long p {
|
||||
mask: linear-gradient(
|
||||
to bottom,
|
||||
rgba(0, 0, 0, 1) 0,
|
||||
rgba(0, 0, 0, 1) 40%,
|
||||
rgba(0, 0, 0, 0) 95%,
|
||||
rgba(0, 0, 0, 0) 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -98,6 +98,24 @@
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "BBC iPlayer"}
|
||||
<a href="https://www.bbc.co.uk/iplayer" target="_blank">
|
||||
<svg width={80} height={wh} viewBox="0 0 605 107" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M.022 34.586h22.003V106.3H.022zM108.188 9.069C100.852 3.025 90.348 0 76.67 0H38.461v106.299h22.598V70.024h15.462c9.118 0 16.848-1.362 23.192-4.088 6.342-2.725 11.174-6.691 14.496-11.894 3.319-5.204 4.98-11.523 4.98-18.956 0-11.299-3.668-19.97-11.001-26.017Zm-17.47 38.877c-3.519 2.728-8.648 4.088-15.387 4.088H61.059V17.989H75.48c6.639 0 11.719 1.39 15.238 4.163 3.518 2.776 5.279 7.088 5.279 12.935 0 5.848-1.761 10.135-5.279 12.859ZM156.058 0h-22.895v106.299h73.295V86.378h-50.4zm127.661 0H259.04c-7.136 16.751-14.198 33.997-21.186 51.737-6.987 17.743-13.852 35.93-20.59 54.562h24.53c2.576-7.731 5.152-15.41 7.731-23.044h43.114c2.576 7.533 5.152 15.216 7.731 23.044h25.126c-6.541-17.641-13.306-35.358-20.294-53.149-6.987-17.789-14.149-35.506-21.483-53.15Zm-27.652 64.225c4.855-13.775 9.862-27.452 15.016-41.033 2.476 6.642 4.98 13.406 7.507 20.294 2.528 6.89 4.981 13.803 7.36 20.739h-29.883Zm106.708-12.121c-4.362-7.631-21.508-44.273-25.571-52.103h-26.018l39.843 71.961V106.3h22.895V71.962L413.917.001h-25.423c-4.163 7.83-21.259 44.372-25.719 52.103Zm84.238 9h44.601v-19.03h-44.601V19.03h50.25V0h-73.145v106.299h75.97V87.27h-53.075zm144.253 23.415c-4.362-7.085-8.772-14.147-13.232-21.186 6.144-2.676 10.876-6.49 14.199-11.447 3.319-4.955 4.98-10.951 4.98-17.989 0-10.902-3.742-19.276-11.224-25.126C578.504 2.924 567.777 0 553.802 0h-38.804v106.299h22.598V67.645h18.287c7.631 12.688 15.015 25.571 22.151 38.654h26.315c-4.363-7.433-8.723-14.692-13.083-21.78Zm-22.747-38.654c-3.668 2.528-9.02 3.791-16.056 3.791h-14.867V17.989h15.016c7.036 0 12.362 1.29 15.982 3.865 3.616 2.579 5.427 6.593 5.427 12.043-.001 5.452-1.836 9.441-5.502 11.968ZM0 0h22.025v18.15H0z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Hulu"}
|
||||
<a href="https://hulu.com" target="_blank">
|
||||
<svg width={wh} height={wh} viewBox="0 0 64 23" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.0599 6.54678H6.7997C5.65659 6.54678 5.08178 6.85375 5.08178 6.85375V0H0V20.98H5.08178V12.5141C5.08178 11.7654 5.69215 11.1591 6.44385 11.1591H9.40148C10.1544 11.1591 10.763 11.7654 10.763 12.5141V20.98H15.8462V11.8589C15.8462 8.02298 13.2767 6.54678 10.0599 6.54678ZM58.9167 6.54678V15.0115C58.9167 15.7614 58.3078 16.368 57.5556 16.368H54.5982C53.8453 16.368 53.2367 15.7614 53.2367 15.0115V6.54678H48.1526V15.4338C48.1526 19.0662 50.4883 20.98 53.9404 20.98H58.9173V20.9541C62.0966 20.9541 64 18.7023 64 15.4338V6.54678H58.9167ZM30.2441 15.0115C30.2441 15.7614 29.6347 16.368 28.8824 16.368H25.9247C25.1719 16.368 24.563 15.7614 24.563 15.0115V6.54678H19.4797V15.4338C19.4797 19.0662 21.8154 20.98 25.2673 20.98H30.2441V20.9541C33.4237 20.9541 35.3274 18.7023 35.3274 15.4338V6.54678H30.2441V15.0115ZM39.2015 20.98H44.2853V0H39.2015V20.98Z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
{:else}
|
||||
<span>{i}</span>
|
||||
{/if}
|
||||
|
||||
@@ -5,18 +5,25 @@
|
||||
export let providers: TMDBContentWatchProviders;
|
||||
</script>
|
||||
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
{#if providers?.flatrate?.length > 0 || providers?.free?.length > 0}
|
||||
<div class="streaming-providers">
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{#if providers?.flatrate?.length > 0}
|
||||
{#each providers.flatrate as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
{#if providers?.free?.length > 0}
|
||||
{#each providers.free as provider}
|
||||
<ProviderIcon i={provider.provider_name} wh={40} />
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.streaming-providers {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
margin-top: auto;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { wlDetailedView } from "@/store";
|
||||
import type { WLDetailedViewOption } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
function detailClicked(d: WLDetailedViewOption) {
|
||||
let dv = get(wlDetailedView);
|
||||
@@ -16,7 +17,7 @@
|
||||
$: dve = $wlDetailedView;
|
||||
</script>
|
||||
|
||||
<div class="menu">
|
||||
<div class={`menu${$page.url?.pathname.startsWith("/search/") ? " on-search-page" : ""}`}>
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">Shown Details</h4>
|
||||
<button
|
||||
@@ -49,13 +50,17 @@
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 58px;
|
||||
right: 92px;
|
||||
|
||||
&:before {
|
||||
left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
div.menu.on-search-page:before {
|
||||
left: 86px;
|
||||
}
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
margin-bottom: 8px;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { activeFilters, serverFeatures } from "@/store";
|
||||
import { activeFilters, clearActiveFilters, serverFeatures } from "@/store";
|
||||
import type { Filters } from "@/types";
|
||||
import { get } from "svelte/store";
|
||||
import Icon from "../Icon.svelte";
|
||||
import tooltip from "../actions/tooltip";
|
||||
|
||||
function filterClicked(type: keyof Filters, f: string) {
|
||||
const af = get(activeFilters);
|
||||
@@ -19,7 +21,18 @@
|
||||
|
||||
<div class="menu">
|
||||
<div class="inner">
|
||||
<h4 class="norm sm-caps">type</h4>
|
||||
<div class="title">
|
||||
<h4 class="norm sm-caps">type</h4>
|
||||
{#if filter?.type?.length > 0 || filter?.status?.length > 0}
|
||||
<button
|
||||
class="plain"
|
||||
use:tooltip={{ text: "Clear", pos: "left" }}
|
||||
on:click={() => clearActiveFilters()}
|
||||
>
|
||||
<Icon i="close-circle" wh={18} />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="type-filter">
|
||||
<button
|
||||
class={`${filter.type.includes("tv") ? "active" : ""}`}
|
||||
@@ -85,19 +98,45 @@
|
||||
<style lang="scss">
|
||||
div.menu {
|
||||
width: 200px;
|
||||
right: 35px;
|
||||
right: 47px;
|
||||
|
||||
&:before {
|
||||
left: 23px;
|
||||
left: 38px;
|
||||
}
|
||||
}
|
||||
|
||||
div.inner {
|
||||
h4 {
|
||||
& > h4 {
|
||||
margin-top: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 8px;
|
||||
.title {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
gap: 5px;
|
||||
/* Always height of when clear filters btn is shown so there is no jump */
|
||||
min-height: 26px;
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 26px;
|
||||
padding: 2px 3px;
|
||||
border-radius: 8px;
|
||||
|
||||
&.manage-on {
|
||||
color: #f3555a;
|
||||
background-color: $text-color;
|
||||
}
|
||||
|
||||
&:first-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
>
|
||||
Last Changed
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "LASTFIN" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("LASTFIN")}
|
||||
>
|
||||
Last Finished
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${sort[0] == "RATING" ? sort[1].toLowerCase() : ""}`}
|
||||
on:click={() => sortClicked("RATING")}
|
||||
@@ -63,6 +69,7 @@
|
||||
<style lang="scss">
|
||||
div {
|
||||
width: 180px;
|
||||
right: 90px;
|
||||
|
||||
&:before {
|
||||
left: 21px;
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
<!-- Extra Details View For Posters -->
|
||||
<script lang="ts">
|
||||
import { userSettings, wlDetailedView } from "@/store";
|
||||
import { RatingSystem, type PosterExtraDetails, type WatchedStatus } from "@/types";
|
||||
import { getOrdinalSuffix, monthsShort, watchedStatuses } from "../util/helpers";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { toShowableRating, toWhichThumb } from "../rating/helpers";
|
||||
import { page } from "$app/stores";
|
||||
|
||||
export let rating: number | undefined;
|
||||
export let status: WatchedStatus | undefined;
|
||||
export let details: PosterExtraDetails | undefined;
|
||||
|
||||
$: dve = $wlDetailedView;
|
||||
$: settings = $userSettings;
|
||||
$: isUsingThumbs = settings && settings.ratingSystem === RatingSystem.Thumbs;
|
||||
|
||||
function formatDate(e: number) {
|
||||
if (!e) {
|
||||
return "Unknown";
|
||||
}
|
||||
const d = new Date(e);
|
||||
return `${d.getDate()}${getOrdinalSuffix(d.getDate())} ${monthsShort[d.getMonth()]} '${String(
|
||||
d.getFullYear()
|
||||
).substring(2, 4)}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if ($page.url?.pathname === "/" || $page.url?.pathname.startsWith("/search/")) && details && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
The `if` statements can't be on their own line to look pretty
|
||||
because that will cause whitespace in the generated markup,
|
||||
which causes the :empty css tag to not work.
|
||||
Can be reverted when its possible to trim whitespace in svelte
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
-->
|
||||
<div>
|
||||
{#if details.dateAdded && dve.includes("dateAdded")}
|
||||
<span title="Date added to watch list">
|
||||
<i><Icon i="calendar" /></i>
|
||||
<span>
|
||||
{formatDate(Date.parse(details.dateAdded))}
|
||||
</span>
|
||||
</span>
|
||||
{/if}{#if details.dateModified && dve.includes("dateModified")}
|
||||
<span title="Date last modified">
|
||||
<i><Icon i="pencil" wh={15} /></i>
|
||||
<span>
|
||||
{formatDate(Date.parse(details.dateModified))}
|
||||
</span>
|
||||
</span>
|
||||
{/if}{#if details.lastWatched && dve.includes("lastWatched")}
|
||||
<span title="Latest season watched">
|
||||
<i><Icon i="play" wh={15} /></i>
|
||||
<span>{details.lastWatched}</span>
|
||||
</span>
|
||||
{/if}{#if dve.includes("statusRating")}
|
||||
<span class="status-rating" title="Status and Rating">
|
||||
{#if !isUsingThumbs}
|
||||
<i><Icon i="star" /></i>
|
||||
{/if}
|
||||
<span class="rating-span">
|
||||
{#if isUsingThumbs}
|
||||
{@const r = toWhichThumb(rating)}
|
||||
{#if r === -1}
|
||||
<i><Icon i="thumb-down" /></i>
|
||||
{:else if r === 0}
|
||||
<span
|
||||
style="display: flex; transform: translate(2px, 3px); font-size: 40px; font-family: 'Shrikhand';"
|
||||
>
|
||||
-
|
||||
</span>
|
||||
{:else if r === 1}
|
||||
<i><Icon i="thumb-up" /></i>
|
||||
{/if}
|
||||
{:else}
|
||||
{toShowableRating(rating)}
|
||||
{/if}
|
||||
</span>
|
||||
{#if status}
|
||||
<i><Icon i={watchedStatuses[status]} wh={15} /></i>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
.extra-details {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 160px;
|
||||
color: white;
|
||||
background-color: $poster-extra-detail-bg-color;
|
||||
border-radius: 10px;
|
||||
transition: opacity 100ms ease-out;
|
||||
pointer-events: none !important;
|
||||
|
||||
& > div {
|
||||
padding: 8px 3px;
|
||||
|
||||
&:empty {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
&.status-rating {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
width: 15px;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating i:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.status-rating .rating-span:empty {
|
||||
/* Can happen when content is not rated and using thumbs system */
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { ExtraDetails, ExtraDetailsGame, Image, WatchedStatus } from "@/types";
|
||||
import Icon from "../Icon.svelte";
|
||||
import type { PosterExtraDetails, Image, WatchedStatus } from "@/types";
|
||||
import {
|
||||
addClassToParent,
|
||||
calculateTransformOrigin,
|
||||
getOrdinalSuffix,
|
||||
isTouch,
|
||||
monthsShort,
|
||||
watchedStatuses
|
||||
monthsShort
|
||||
} from "@/lib/util/helpers";
|
||||
import { goto } from "$app/navigation";
|
||||
import { baseURL, removeWatched, updatePlayed } from "../util/api";
|
||||
@@ -16,8 +14,8 @@
|
||||
import PosterStatus from "./PosterStatus.svelte";
|
||||
import PosterRating from "./PosterRating.svelte";
|
||||
import { wlDetailedView } from "@/store";
|
||||
import { page } from "$app/stores";
|
||||
import { decode } from "blurhash";
|
||||
import ExtraDetails from "./ExtraDetails.svelte";
|
||||
|
||||
export let id: number | undefined = undefined; // Watched list id
|
||||
export let media: {
|
||||
@@ -33,7 +31,9 @@
|
||||
export let small = false;
|
||||
export let disableInteraction = false;
|
||||
export let hideButtons = false;
|
||||
export let extraDetails: ExtraDetailsGame | undefined = undefined;
|
||||
export let extraDetails: PosterExtraDetails | undefined = undefined;
|
||||
export let fluidSize = false;
|
||||
export let pinned = false;
|
||||
// When provided, default click handlers will instead run this callback.
|
||||
export let onClick: (() => void) | undefined = undefined;
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
|
||||
// If poster is active (scaled up)
|
||||
let posterActive = false;
|
||||
// If mouse in on poster. Added to fix #656.
|
||||
let mouseOverPoster = false;
|
||||
|
||||
let containerEl: HTMLDivElement;
|
||||
let bhCanvas: HTMLCanvasElement;
|
||||
@@ -72,7 +74,6 @@
|
||||
}
|
||||
|
||||
function handleInnerKeyUp(e: KeyboardEvent) {
|
||||
console.log(e.target);
|
||||
if (e.key === "Enter" && (e.target as HTMLElement)?.id === "ilikemoviessueme") {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
@@ -107,8 +108,13 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (small && containerEl) {
|
||||
containerEl.classList.add("small");
|
||||
if (containerEl) {
|
||||
if (small) {
|
||||
containerEl.classList.add("small");
|
||||
}
|
||||
if (fluidSize) {
|
||||
containerEl.classList.add("fluid-size");
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -117,6 +123,7 @@
|
||||
<!-- svelte-ignore a11y-no-noninteractive-element-interactions -->
|
||||
<li
|
||||
on:mouseenter={(e) => {
|
||||
mouseOverPoster = true;
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
if (!isTouch()) {
|
||||
posterActive = true;
|
||||
@@ -124,11 +131,44 @@
|
||||
}}
|
||||
on:focusin={(e) => {
|
||||
if (!posterActive) calculateTransformOrigin(e);
|
||||
if (!isTouch()) {
|
||||
posterActive = true;
|
||||
}
|
||||
}}
|
||||
on:focusout={() => {
|
||||
if (!isTouch() && !mouseOverPoster) {
|
||||
// Only on !isTouch (to match focusin) to avoid breaking a tap and hold on link on mobile.
|
||||
// and only if mouse isn't still over the poster, fixes focusout on click of rating/status
|
||||
// poster buttons causing poster to shrink until refocused with click/mouse out & in again.
|
||||
posterActive = false;
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => {
|
||||
mouseOverPoster = false;
|
||||
posterActive = false;
|
||||
const ae = document.activeElement;
|
||||
if (
|
||||
ae &&
|
||||
ae instanceof HTMLElement &&
|
||||
(ae.parentElement?.id === "ilikemoviessueme" ||
|
||||
ae.parentElement?.parentElement?.id === "ilikemoviessueme")
|
||||
) {
|
||||
// Stops the poster being re-focused after the browser window
|
||||
// loses focus, then regains it (ex: you middle click the poster,
|
||||
// go to the opened tab (or lose browser window focus, then when
|
||||
// you come back the poster is sent `focusin` and stuck activated
|
||||
// until mouseleave again).
|
||||
ae.blur();
|
||||
}
|
||||
}}
|
||||
on:mouseleave={() => (posterActive = false)}
|
||||
on:click={() => (posterActive = true)}
|
||||
on:keyup={(e) => {
|
||||
if (e.key === "Tab") {
|
||||
e.currentTarget.scrollIntoView({ block: "center" });
|
||||
}
|
||||
}}
|
||||
on:keypress={() => console.log("on kpress")}
|
||||
class={`${posterActive ? "active " : ""}`}
|
||||
class={`${posterActive ? "active " : ""}${pinned ? "pinned " : ""}`}
|
||||
>
|
||||
<div
|
||||
class={`container${!poster || (!media.coverId && !media.poster?.path) ? " details-shown" : ""}`}
|
||||
@@ -152,23 +192,16 @@
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
{#if $page.url?.pathname === "/" && extraDetails && dve && dve.length > 0}
|
||||
<div class="extra-details">
|
||||
<!--
|
||||
This is one line because svelte leaves whitespace,
|
||||
which causes the :empty css tag to not work.
|
||||
Can be reverted when its possible to trim whitespace in svelte
|
||||
OR when :empty tag is updated in browsers to new spec and counts whitespace as empty.
|
||||
So ye this is probably gonna be one line until the end of time.
|
||||
-->
|
||||
<!-- prettier-ignore -->
|
||||
<div>{#if dve.includes("dateAdded")}<span title="Date added to watch list"><i><Icon i="calendar" /></i><span>{formatDate(Date.parse(extraDetails.dateAdded))}</span></span>{/if}{#if dve.includes("dateModified")}<span title="Date last modified"><i><Icon i="pencil" wh={15} /></i><span>{formatDate(Date.parse(extraDetails.dateModified))}</span></span>{/if}{#if dve.includes("statusRating")}<span class="status-rating" title="Status and Rating"><i><Icon i="star" /></i><span>{rating}</span>{#if status}<i><Icon i={watchedStatuses[status]} wh={15} /></i>{/if}</span>{/if}</div>
|
||||
</div>
|
||||
{#if id && !posterActive}
|
||||
<!-- Must be on watched list, and poster not hovered -->
|
||||
<ExtraDetails details={extraDetails} {status} {rating} />
|
||||
{/if}
|
||||
<div
|
||||
on:click={() => {
|
||||
on:click={(e) => {
|
||||
if (typeof onClick !== "undefined") {
|
||||
onClick();
|
||||
// Prevent the link inside this div from being clicked in this case.
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (posterActive && link) goto(link);
|
||||
@@ -177,21 +210,17 @@
|
||||
id="ilikemoviessueme"
|
||||
class="inner"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
tabindex="-1"
|
||||
>
|
||||
<h2>
|
||||
{#if typeof onClick === "undefined" && link}
|
||||
<a data-sveltekit-preload-data="tap" href={link}>
|
||||
{title}
|
||||
</a>
|
||||
{:else}
|
||||
<a data-sveltekit-preload-data="tap" href={link} class="small-scrollbar">
|
||||
<h2>
|
||||
{title}
|
||||
{/if}
|
||||
{#if year}
|
||||
<time>{year}</time>
|
||||
{/if}
|
||||
</h2>
|
||||
<span>{media.summary}</span>
|
||||
{#if year}
|
||||
<time>{year}</time>
|
||||
{/if}
|
||||
</h2>
|
||||
<span>{media.summary}</span>
|
||||
</a>
|
||||
|
||||
{#if !hideButtons}
|
||||
<div class="buttons">
|
||||
@@ -208,6 +237,19 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
li.pinned:not(.active) .container {
|
||||
outline: 3px solid gold;
|
||||
}
|
||||
|
||||
li {
|
||||
&:not(.active) {
|
||||
.container .inner,
|
||||
.container .inner .buttons {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
@@ -222,6 +264,11 @@
|
||||
// aspect-ratio: 2/3;
|
||||
transition: transform 150ms ease;
|
||||
|
||||
&.fluid-size {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -273,54 +320,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.extra-details {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 14px;
|
||||
width: 160px;
|
||||
color: white;
|
||||
background-color: $poster-extra-detail-bg-color;
|
||||
border-radius: 10px;
|
||||
transition: opacity 100ms ease-out;
|
||||
|
||||
& > div {
|
||||
padding: 8px 3px;
|
||||
|
||||
&:empty {
|
||||
padding: 0px;
|
||||
}
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
&:not(:last-child) {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
width: 15px;
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.status-rating i:last-of-type {
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.inner {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
@@ -333,6 +332,11 @@
|
||||
background-color: transparent;
|
||||
transition: opacity 150ms cubic-bezier(0.19, 1, 0.22, 1);
|
||||
|
||||
& > a {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family:
|
||||
sans-serif,
|
||||
@@ -378,23 +382,16 @@
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
.active & {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
|
||||
.extra-details {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.small:hover,
|
||||
&.small:focus-within {
|
||||
.active &.small {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
.active &,
|
||||
&:global(.details-shown) {
|
||||
img {
|
||||
filter: blur(4px) grayscale(80%);
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
|
||||
&:not(.no-zoom) {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
&:has(:focus-visible) {
|
||||
transform: scale(1.3);
|
||||
z-index: 99;
|
||||
}
|
||||
@@ -151,13 +151,13 @@
|
||||
|
||||
&:not(:not(.no-zoom)) {
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
&:has(:focus-visible) {
|
||||
outline: 3px solid $text-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus-within,
|
||||
&:has(:focus-visible),
|
||||
&:global(.details-shown) {
|
||||
.inner {
|
||||
color: white;
|
||||
|
||||