Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d8b050615 | |||
| 63988f833f | |||
| e391d5dc69 | |||
| cba3d11f64 | |||
| 5d95aa4b41 | |||
| 41dff624bb | |||
| 39a77b4428 | |||
| 9b74728afb | |||
| 113ccbbbfd | |||
| 87033f423d | |||
| 3a52a9a592 | |||
| 9b39617650 | |||
| dd1503846d | |||
| 92edb75de9 | |||
| dc2b9a1a63 | |||
| 7b65c8af0f | |||
| cf3168d59c | |||
| fb1017f551 | |||
| bff354fcc3 | |||
| 0be6fdb346 | |||
| a73651808e | |||
| 9da0913d48 | |||
| 0a38171836 | |||
| d009528388 | |||
| cd34ecba61 | |||
| 769e30c9c7 | |||
| ba961bf5c3 | |||
| efddcaed07 | |||
| 76fdbe3cd0 | |||
| f4b7f66104 | |||
| 12ceb13bcb | |||
| 5a5ae0ad83 | |||
| 05e24e42c6 | |||
| e5d93fd8a2 | |||
| bf5d44f404 | |||
| 5abbb47fbf | |||
| 40b54c1065 | |||
| 3967a3f13f | |||
| 1f4b917661 | |||
| 2202b11e39 | |||
| d32a1ecb39 | |||
| 347113be68 | |||
| 68150b38ec | |||
| dc9e7e9309 | |||
| 6457146214 | |||
| baba8c8c60 | |||
| 31ddcacf47 | |||
| be94f1d2b7 | |||
| d7449642ba | |||
| dbc8b74893 | |||
| 9d5e489675 | |||
| 4e6e71687b | |||
| 8c8d604bc6 | |||
| 39b82335eb | |||
| 217be719e4 | |||
| 0da32164a3 | |||
| db72d05d79 | |||
| 88a7542064 | |||
| 19c773976d | |||
| a16c769273 | |||
| f022dc702c | |||
| c2f72dbd9c | |||
| d777630e84 | |||
| f5bfa29877 | |||
| 9b64932af1 | |||
| bf0594c1e8 | |||
| fee82df59d | |||
| 61382de4d2 | |||
| e65ba9d1d9 | |||
| 6654d85448 | |||
| eabdb47cfa | |||
| 643ca0fd38 | |||
| a08b19675a | |||
| 38fba0ac10 | |||
| f23552c170 | |||
| 448fdd9df3 | |||
| ca428327e2 | |||
| f7cca74a41 | |||
| 86286b967f | |||
| 8243838bfb | |||
| 5bebb754ed | |||
| c8920c911d | |||
| dd0bae137f | |||
| d5baee0df7 | |||
| 764a25be6c | |||
| c019162994 | |||
| 05f9813be8 | |||
| f285d1a8f4 | |||
| 4cc9bd3edb | |||
| 34f7f54e72 | |||
| 3fe2d2b155 | |||
| 3461a4ec74 | |||
| 475c9b6ab4 | |||
| 325666c30d | |||
| 92816b02a0 | |||
| 363c648964 | |||
| 2f929e99d5 | |||
| f14be89680 | |||
| ecd027f603 | |||
| f80f237143 | |||
| dbfc354353 | |||
| 28a6d85672 | |||
| b861cfcec3 | |||
| 08e0021535 | |||
| b3bf111f82 | |||
| 23ed5ffca8 | |||
| cc9f246c9a | |||
| 75d75248e1 | |||
| c34a0878b2 | |||
| 681212566b | |||
| df37de00e1 | |||
| fc900d8e93 | |||
| 57ce143ebe | |||
| 3eb2810a31 | |||
| b8598ba404 | |||
| 579428d86b | |||
| 4da8730b6f | |||
| e980a1f70e | |||
| f73105e9c7 | |||
| 755b08b746 | |||
| 5e412f67b9 | |||
| 4ee38a2625 | |||
| dc6ead0fb3 |
@@ -1,15 +0,0 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
/doc
|
||||
|
||||
# Ignore files for PNPM, NPM and YARN
|
||||
pnpm-lock.yaml
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
@@ -1,27 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
parser: "@typescript-eslint/parser",
|
||||
extends: ["plugin:svelte/recommended"],
|
||||
plugins: ["@typescript-eslint"],
|
||||
ignorePatterns: ["*.cjs", "*.config.js"],
|
||||
overrides: [
|
||||
{
|
||||
files: ["*.svelte"],
|
||||
parser: "svelte-eslint-parser",
|
||||
parserOptions: {
|
||||
parser: "@typescript-eslint/parser",
|
||||
},
|
||||
},
|
||||
],
|
||||
parserOptions: {
|
||||
project: "./tsconfig.json",
|
||||
extraFileExtensions: [".svelte"],
|
||||
sourceType: "module",
|
||||
ecmaVersion: 2020,
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
es2017: true,
|
||||
node: false,
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
<!-- Make sure your code is formatted by running `npm run format` or using prettier manually. -->
|
||||
|
||||
<!-- AI Disclosure: <If you used AI to write the code, please disclose it by uncommenting this line and describing the usage. If AI wrote any of the code, do you fully understand it?> -->
|
||||
|
||||
### Changes made
|
||||
|
||||
<!-- Describe changes made here. If changes are visual a screenshot could be useful! -->
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Web UI
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ui"
|
||||
|
||||
# Server
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/server"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "server"
|
||||
|
||||
# Workflows
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/" # / will look in /.github/workflows directory by default
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "workflow"
|
||||
@@ -19,29 +19,29 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.2.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
username: sbondco
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -18,37 +18,37 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@v4.2.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4.2.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
username: sbondco
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v6.2.0
|
||||
with:
|
||||
images: |
|
||||
${{ env.image }}
|
||||
ghcr.io/${{ env.image }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
|
||||
@@ -21,10 +21,10 @@ jobs:
|
||||
run:
|
||||
working-directory: doc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
@@ -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@v4
|
||||
uses: peaceiris/actions-gh-pages@v4.1.0
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Build output to publish to the `gh-pages` branch:
|
||||
|
||||
@@ -16,10 +16,10 @@ jobs:
|
||||
run:
|
||||
working-directory: doc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7.0.0
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
|
||||
@@ -11,12 +11,22 @@ on:
|
||||
- "server/**"
|
||||
|
||||
jobs:
|
||||
format_check_go:
|
||||
test_and_format_check_go:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v7.0.0
|
||||
|
||||
- uses: actions/setup-go@v7.0.0
|
||||
with:
|
||||
go-version-file: "server/go.mod"
|
||||
|
||||
- name: go test all packages
|
||||
run: go test ./...
|
||||
|
||||
- name: gofmt test
|
||||
run: |
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7.0.0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: "20"
|
||||
node-version: 24
|
||||
cache: "npm"
|
||||
|
||||
- name: Get dependencies
|
||||
|
||||
@@ -4,8 +4,14 @@ First of all, thank you for looking into contributing! 🫡
|
||||
|
||||
Feel free to contribute in any way: 🐞 bug reports, 💡 ideas, 🥖 new features etc - everything is welcome!
|
||||
|
||||
For big PRs (lots of changes/big change in the way something works), if you'd like, it can be discussed first in an issue (dont want to waste anyones time!).
|
||||
Big pull requests (anything more than a few lines) should be discussed first in an issue (dont want to waste anyones time!).
|
||||
|
||||
## Project Board
|
||||
|
||||
We have a [board to organize new features and bug fixes](https://github.com/orgs/sbondCo/projects/9/views/2) to be worked on. If you see something you'd like to work on, just ask in the issue and it can be assigned to you. Even if it isn't set for the next milestone already, it can be.
|
||||
|
||||
## AI
|
||||
|
||||
If you use AI, please disclose the usage in your pull request.
|
||||
|
||||
Fully understanding any code that is submitted is a must because someone's gotta understand it!
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#
|
||||
# Backend
|
||||
#
|
||||
FROM golang:1.25-alpine AS server
|
||||
FROM golang:1.26-alpine AS server
|
||||
|
||||
WORKDIR /server
|
||||
|
||||
@@ -18,7 +18,7 @@ RUN go mod download && GOOS=linux CGO_ENABLED=1 CGO_CFLAGS="-D_LARGEFILE64_SOURC
|
||||
#
|
||||
# Frontend
|
||||
#
|
||||
FROM node:20-alpine AS ui
|
||||
FROM node:24-alpine AS ui
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json vite.config.ts svelte.config.js tsconfig.json ./
|
||||
@@ -30,7 +30,7 @@ RUN npm install && npm run build
|
||||
#
|
||||
# Production
|
||||
#
|
||||
FROM node:20-alpine AS runner
|
||||
FROM node:24-alpine AS runner
|
||||
|
||||
COPY --from=server /server/watcharr /
|
||||
COPY --from=ui /app/build /ui
|
||||
|
||||
@@ -4,7 +4,7 @@ A basic top-level view of the features included in Watcharr.
|
||||
|
||||
When the word `watched` is used, assume it can be `played` for games too.
|
||||
|
||||
Have a look at when this document was last updated, it may have missing features.
|
||||
**NOTE:** Have a look at when this document was last updated, it may have missing features. This document was made years after starting the project, so I have no doubt I've missed some stuff, this is a good overview anyways.
|
||||
|
||||
- Watched List
|
||||
- Supported Content:
|
||||
@@ -25,10 +25,12 @@ Have a look at when this document was last updated, it may have missing features
|
||||
- Available streaming providers in your region
|
||||
- Cast
|
||||
- Similar content
|
||||
- List of all seasons and their episodes (for tv shows).
|
||||
- Person detail pages
|
||||
- Basic overview
|
||||
- All credits (movies & tv they appear in or worked on)
|
||||
- Searching for content
|
||||
- Searching for media
|
||||
- Inline filters (ex: `y:2008` to get media released in 2008)
|
||||
- Custom tags
|
||||
- Discovery page
|
||||
- Following other users
|
||||
|
||||
@@ -16,9 +16,19 @@ With [some extra configuration](https://watcharr.app/docs/server_config/game-sup
|
||||
|
||||
I am built with Go and Svelte(Kit).
|
||||
|
||||
Feel free to abuse this demo instance (nicely), which runs on the latest `dev` build (there may be bugs, as new features are tested on here too): [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
### Demo
|
||||
|
||||
[Track progress for the next version](https://github.com/orgs/sbondCo/projects/9/views/3).
|
||||
Feel free to abuse this demo instance (nicely). It runs on the latest `dev` build so there may be bugs, as new features are tested on here too. The demo is a worst-case scenario for speed (which is why I like it as a testing ground), if you host it yourself the app will be snappy.
|
||||
|
||||
Demo: [https://beta.watcharr.app/](https://beta.watcharr.app/)
|
||||
|
||||
**NOTE:** There is no demo account, just type in a random username/password (smashing hand into keyboard is supported), then click `Not a user?` at the bottom and a `Sign Up` button will appear.
|
||||
|
||||
### Track new features
|
||||
|
||||
Most patches are tracked through [our project board](https://github.com/orgs/sbondCo/projects/9/views/3), though I am very unorganised so expect surprise updates (or don't, if you like surprises)!
|
||||
|
||||
You can also [view a list of all current features](FEATURES.md).
|
||||
|
||||
### Contents
|
||||
|
||||
@@ -26,6 +36,7 @@ Feel free to abuse this demo instance (nicely), which runs on the latest `dev` b
|
||||
- [Set Up](#set-up)
|
||||
- [Community Made Tools](#community-made-tools)
|
||||
- [Getting Help](#getting-help)
|
||||
- [License](#license)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
# Screenshots
|
||||
@@ -72,6 +83,8 @@ If something isn't working for you or you are stuck, [creating an issue](https:/
|
||||
|
||||
You can also [join our space on Matrix](https://matrix.to/#/#watcharr:matrix.org) for support.
|
||||
|
||||
I'll do my best to reply!
|
||||
|
||||
# License
|
||||
|
||||
This project is licensed under the GPLv3 license. You should see the [LICENSE](LICENSE) file located in the root folder of this project for the full license text, if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 273 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 878 KiB |
|
After Width: | Height: | Size: 360 KiB |
@@ -0,0 +1,5 @@
|
||||
## Changelog Data
|
||||
|
||||
Changelog images, etc go in this folder.
|
||||
|
||||
[View the real changelog for Watcharr.](../CHANGELOG.md)
|
||||
@@ -14,7 +14,12 @@ Hopefully it is useful for others with similar files or in scenarios where its t
|
||||
|
||||
## Format
|
||||
|
||||
Each line is a new entry. The name of the content (show/movie) must be provided, the year can be optionally provided surrounded by brackets. Doesn't support specifying if name is for a show or movie, the importer will only automatically match on full search matches, if there are multiple results, you will be asked to pick the correct one.
|
||||
Each line is a new entry. The name of the content (show/movie) must be provided. Doesn't support specifying if name is for a show or movie, the importer will only automatically match on full search matches, if there are multiple results, you will be asked to pick the correct one.
|
||||
|
||||
Optionally provide:
|
||||
|
||||
- The year in brackets (eg: `(1983)`)
|
||||
- A rating (out of 10) in square brackets (eg: `[4]` or `[6.9]`)
|
||||
|
||||
```
|
||||
<name> [(<year>)]
|
||||
|
||||
@@ -17,7 +17,7 @@ This section assumes you will be forking the repo on Github, of course you can a
|
||||
|
||||

|
||||
|
||||
2. Get the code by cloning your fork
|
||||
2. Get the code by cloning your fork (edit the command and replace `<your username>` with your GitHub username or replace the whole url with the correct one pointing to your fork).
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your username>/Watcharr.git && \
|
||||
@@ -34,7 +34,7 @@ You only need to do this once after pulling the codebase for the first time, and
|
||||
npm i
|
||||
```
|
||||
|
||||
2. Install server dependencies (in the `server` folder)
|
||||
2. Install server dependencies (in the `server` folder). Go does this automatically if you try starting the server and it notices you don't have them.
|
||||
|
||||
```
|
||||
go get .
|
||||
@@ -44,16 +44,18 @@ You only need to do this once after pulling the codebase for the first time, and
|
||||
|
||||
To run the code, you will need to open up two terminals, one for the frontend and the other for the backend.
|
||||
|
||||
We have `Makefile`s for the frontend and server, so you can simply call `make` in each directory. Most Linux distros (as far as I know) include `make`, so you don't need to install anything. If you don't have `make`, you can either install it (lookup how to get "GNU Make" for your specific OS) OR you can just look inside the Makefiles and manually run the first command in each.
|
||||
|
||||
1. Run the frontend (first terminal, in the project root folder)
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
make
|
||||
```
|
||||
|
||||
2. Run the server (second terminal, in the project root folder)
|
||||
2. Run the server (second terminal, in the `server` folder)
|
||||
|
||||
```bash
|
||||
npm run server
|
||||
make
|
||||
```
|
||||
|
||||
**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):
|
||||
|
||||
@@ -35,7 +35,7 @@ Backing up this way is discouraged because new important files could be added la
|
||||
|
||||
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.db` & `watcharr.db-wal` 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).
|
||||
|
||||
@@ -4,8 +4,8 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: watcharr
|
||||
container_name: watcharr-dev
|
||||
ports:
|
||||
- 3080:3080
|
||||
- 3081:3080
|
||||
volumes:
|
||||
- ./container_data:/data
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import prettier from "eslint-config-prettier";
|
||||
import path from "node:path";
|
||||
import js from "@eslint/js";
|
||||
import svelte from "eslint-plugin-svelte";
|
||||
import { defineConfig, includeIgnoreFile } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import ts from "typescript-eslint";
|
||||
import svelteConfig from "./svelte.config.js";
|
||||
|
||||
const gitignorePath = path.resolve(import.meta.dirname, ".gitignore");
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
ts.configs.recommended,
|
||||
svelte.configs.recommended,
|
||||
prettier,
|
||||
svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser /* ...globals.node */ },
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
parser: ts.parser,
|
||||
ecmaVersion: "latest",
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
extraFileExtensions: [".svelte"],
|
||||
svelteConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Override or add rule settings here.
|
||||
rules: {
|
||||
"@typescript-eslint/no-empty-object-type": [
|
||||
"error",
|
||||
{
|
||||
// Allowing this because it's nice to create an empty
|
||||
// interface that extends a base interface for specific
|
||||
// use, even if it is currently empty, incase i add stuff
|
||||
// to it in the future. It's also less confusing to read
|
||||
// the actual type name I want instead of the base type
|
||||
// everywhere in certain scenarios.
|
||||
allowInterfaces: "with-single-extends",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"license": "GPL-3.0-only",
|
||||
"version": "3.0.1",
|
||||
"version": "4.2.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -10,33 +10,33 @@
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"prepare": "svelte-kit sync",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"lint": "prettier --check ./src/ && eslint ./src/",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vite-pwa/sveltekit": "^0.6.6",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-svelte": "^2.45.1",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"sass": "^1.97.3",
|
||||
"svelte": "^5.17.3",
|
||||
"svelte-check": "^4.1.3",
|
||||
"svelte-eslint-parser": "^0.42.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.5"
|
||||
"@eslint/js": "10.0.1",
|
||||
"@sveltejs/adapter-node": "5.5.7",
|
||||
"@sveltejs/kit": "2.69.3",
|
||||
"@types/papaparse": "5.3.15",
|
||||
"@vite-pwa/sveltekit": "1.1.0",
|
||||
"eslint": "10.7.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-svelte": "3.20.0",
|
||||
"globals": "17.7.0",
|
||||
"prettier": "3.9.5",
|
||||
"prettier-plugin-svelte": "4.1.1",
|
||||
"sass": "1.101.0",
|
||||
"svelte": "5.56.4",
|
||||
"svelte-check": "4.7.2",
|
||||
"svelte-eslint-parser": "1.8.0",
|
||||
"svelte-preprocess": "6.0.5",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.63.0",
|
||||
"vite": "8.1.4"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"papaparse": "^5.4.1"
|
||||
"blurhash": "2.0.5",
|
||||
"papaparse": "5.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.0.1
|
||||
4.2.1
|
||||
@@ -53,6 +53,12 @@ func CreateCacheKey(name string, u ...any) string {
|
||||
}
|
||||
case int:
|
||||
appnd(strconv.Itoa(vv))
|
||||
case bool:
|
||||
if vv {
|
||||
appnd("1")
|
||||
} else {
|
||||
appnd("0")
|
||||
}
|
||||
default:
|
||||
// This should never happen, but incase of unknown
|
||||
// value passed, hopefully this should make it easier
|
||||
|
||||
@@ -1,26 +1,58 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/database/migrate"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Create a new database connection.
|
||||
// Also runs migrations, etc, before returning connection.
|
||||
// Any error returned from this func should always make our app Exit (caller
|
||||
// handled).
|
||||
//
|
||||
// NOTE: Our mock db used in tests mimics this function, so if this func is
|
||||
// changed, you should look at the mock db to make it match if it makes
|
||||
// sense, so our tests stay accurate to prod.
|
||||
func New() (*gorm.DB, error) {
|
||||
slog.Info("New: Opening new database connection")
|
||||
// Open the database.
|
||||
db, err := gorm.Open(
|
||||
sqlite.Open(path.Join(config.DataPath, "watcharr.db")),
|
||||
&gorm.Config{TranslateError: true},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("New: Opening database failed.")
|
||||
return nil, err
|
||||
}
|
||||
// Setup the db (migrations, etc)
|
||||
if err := Setup(db); err != nil {
|
||||
slog.Error("New: Setting up connection failed!", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Setup configures our db connection and applies migrations.
|
||||
//
|
||||
// NOTE: This exists as a separate function so it can be reused by our testutil
|
||||
// package that we want to have configured in the same way as the main db so
|
||||
// that tests reflect real life.
|
||||
func Setup(db *gorm.DB) error {
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("Setup: Configuring connection failed!", "error", err)
|
||||
return err
|
||||
}
|
||||
// Perform auto migration.
|
||||
err = db.AutoMigrate(
|
||||
slog.Info("Setup: AutoMigrating")
|
||||
err := db.AutoMigrate(
|
||||
&migrate.MigrationRecord{},
|
||||
&entity.User{},
|
||||
&entity.UserServices{},
|
||||
&entity.Content{},
|
||||
@@ -36,7 +68,123 @@ func New() (*gorm.DB, error) {
|
||||
&entity.Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
slog.Error("Setup: Auto migration failed.")
|
||||
return err
|
||||
}
|
||||
return db, nil
|
||||
slog.Info("Setup: AutoMigrated")
|
||||
// Perform our manual migrations.
|
||||
if err := migrate.Now(db); err != nil {
|
||||
slog.Error("Setup: Manual migrations failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
// Optimize database.
|
||||
if err := optimize(db); err != nil {
|
||||
slog.Error("Setup: Optimizing database failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure our SQLite database connection.
|
||||
// Some PRAGMAs need to be defined per-connection, so we do that here.
|
||||
func configure(db *gorm.DB) error {
|
||||
slog.Info("configure: Configuring connection.")
|
||||
|
||||
// Synchronous: https://sqlite.org/pragma.html#pragma_synchronous
|
||||
// Configured to `FULL` because I'm slightly confused.
|
||||
// `NORMAL` is recommended for most apps with WAL, but you
|
||||
// lose "durability", which doesn't sound like a good thing to me...
|
||||
// personally I'm okay with less performance for the best durability.
|
||||
if res := db.Exec("PRAGMA synchronous=2"); res.Error != nil {
|
||||
slog.Error("configure: Configuring synchronous failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("configure: Configured synchronous.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Optimize the database.
|
||||
func optimize(db *gorm.DB) error {
|
||||
slog.Info("optimize: Running optimizations.")
|
||||
|
||||
// WAL Checkpoint (aka commit anything in the WAL to the main db).
|
||||
// Seems best to do this to make sure it has happened, especially since
|
||||
// we are running vacuum next.
|
||||
// https://sqlite.org/pragma.html#pragma_wal_checkpoint
|
||||
timeBeforeQuery := time.Now()
|
||||
if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil {
|
||||
slog.Error("optimize: Checkpoint failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("optimize: Checkpointed.", "took", time.Since(timeBeforeQuery))
|
||||
|
||||
// Optimize pragma.
|
||||
// Running with recommended argument for our new long-living connection.
|
||||
// https://sqlite.org/pragma.html#pragma_optimize
|
||||
timeBeforeQuery = time.Now()
|
||||
if res := db.Exec("PRAGMA optimize=0x10002"); res.Error != nil {
|
||||
slog.Error("optimize: Optimize pragma failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("optimize: Optimize pragma succeeded.",
|
||||
"took", time.Since(timeBeforeQuery))
|
||||
|
||||
// Vacuum.
|
||||
// > VACUUM rebuilds the database file, repacking it into a minimal amount
|
||||
// > of disk space.
|
||||
// https://sqlite.org/lang_vacuum.html
|
||||
timeBeforeQuery = time.Now()
|
||||
if res := db.Exec("VACUUM"); res.Error != nil {
|
||||
slog.Error("optimize: Vacuum failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("optimize: Vacuumed successfully.",
|
||||
"took", time.Since(timeBeforeQuery))
|
||||
|
||||
// WAL Checkpoint (aka commit anything in the WAL to the main db).
|
||||
// Do this after vacuum too to ensure we have a clean slate for this
|
||||
// startup.
|
||||
// https://sqlite.org/pragma.html#pragma_wal_checkpoint
|
||||
timeBeforeQuery = time.Now()
|
||||
if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil {
|
||||
slog.Error("optimize: Checkpoint failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("optimize: Checkpointed.", "took", time.Since(timeBeforeQuery))
|
||||
|
||||
slog.Info("optimize: Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Optimize task that is scheduled and ran every whenever.
|
||||
// So this func is for optimzations that we want to re-run every time the
|
||||
// task is scheduled for.
|
||||
func TaskOptimize(db *gorm.DB) error {
|
||||
slog.Info("TaskOptimize: Running optimizations.")
|
||||
|
||||
// WAL Checkpoint (aka commit anything in the WAL to the main db).
|
||||
// To avoid our WAL file becoming huge, we checkpoint regularly.
|
||||
// https://sqlite.org/pragma.html#pragma_wal_checkpoint
|
||||
timeBeforeQuery := time.Now()
|
||||
if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil {
|
||||
slog.Error("TaskOptimize: Checkpoint failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("TaskOptimize: Checkpointed.",
|
||||
"took", time.Since(timeBeforeQuery))
|
||||
|
||||
// Optimize pragma.
|
||||
// No args for our task as recommended.
|
||||
// https://sqlite.org/pragma.html#pragma_optimize
|
||||
timeBeforeQuery = time.Now()
|
||||
if res := db.Exec("PRAGMA optimize"); res.Error != nil {
|
||||
slog.Error("TaskOptimize: Optimize pragma failed!")
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("TaskOptimize: Optimize pragma succeeded.",
|
||||
"took", time.Since(timeBeforeQuery))
|
||||
|
||||
slog.Info("TaskOptimize: Done.")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,3 +12,11 @@ type GormModel struct {
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
// Same as GormModel, but without the DeletedAt field,
|
||||
// so use this for tables where we don't need soft deletion.
|
||||
type GormModelNoDel struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ type Activity struct {
|
||||
// secured (users can only view their own activities).
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// ID of watched list item this activity is linked to.
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
WatchedID uint `json:"watchedId" gorm:"not null;index"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
@@ -54,4 +54,18 @@ type Activity struct {
|
||||
Data string `json:"data" gorm:"not null"`
|
||||
// Custom date for the activity, that the user can define.
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
// Count this Activity as a Play?
|
||||
// Currently this was the best way I could see forward for implementing
|
||||
// counting plays of media that doesn't involve inefficient querying of
|
||||
// the actitivties table (or a whole new table, which would create extra
|
||||
// complexities itself, ie, plays/activity showing different records).
|
||||
// We write to this field when creating the activity to count is as a play
|
||||
// or not (ie when we create STATUS CHANGE activities with status of
|
||||
// FINISHED, imports, etc).
|
||||
// We won't support the user (or the system) modifying this value after
|
||||
// creation; if the user wants to delete a 'Play', they should delete the
|
||||
// activity.
|
||||
// Indexed (check migrations) to make search faster, since we frequently
|
||||
// do it over the whole table for watched sorting at the moment.
|
||||
CountAsPlay bool `json:"countAsPlay"`
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ const (
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
func (r WatchedStatus) IsValid() bool {
|
||||
switch r {
|
||||
case FINISHED,
|
||||
WATCHING,
|
||||
PLANNED,
|
||||
HOLD,
|
||||
DROPPED:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Watched struct {
|
||||
dbmodel.GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
|
||||
@@ -8,7 +8,7 @@ import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
// since episodes can be removed and re-added. For this reason we store season and episodes nums instead
|
||||
// of just the episode id.
|
||||
type WatchedEpisode struct {
|
||||
dbmodel.GormModel
|
||||
dbmodel.GormModelNoDel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
|
||||
@@ -4,7 +4,7 @@ import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
|
||||
// UniqueIndex applied between WatchedID and SeasonNumber to avoid duplicates incase logic fails.
|
||||
type WatchedSeason struct {
|
||||
dbmodel.GormModel
|
||||
dbmodel.GormModelNoDel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package migrate
|
||||
|
||||
import "time"
|
||||
|
||||
// Record of all our applied migrations.
|
||||
type MigrationRecord struct {
|
||||
// Migration ID
|
||||
ID string `gorm:"primarykey"`
|
||||
// When migration was applied on this db.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
// ID of migration, stick to YYYYMMDDHHMM.
|
||||
ID string
|
||||
// Apply migration func.
|
||||
Up func(tx *gorm.DB) error
|
||||
// When `true`, the migration is not run inside of a transaction.
|
||||
// You should only use this when is required by sqlite that the command
|
||||
// we need to run for eg cannot be ran from within a transaction!
|
||||
// YOU SHOULD ENSURE YOU ONLY RUN ONE COMMAND PER MIGRATION WHEN USING
|
||||
// THIS WITH `TRUE` TO AVOID BEING LEFT IN A BAD OR INCOMPLETE STATE!!!
|
||||
//
|
||||
// ALSO: All statements used with this should take into account that since,
|
||||
// it isn't inside of a transaction, it's possible the migration succeeds,
|
||||
// but creating the record of it doesn't. If a user starts the server again
|
||||
// after we error in this case, the migration will run again, so it must
|
||||
// not break data integrity or make any assumptions of it being the first
|
||||
// time running!
|
||||
UNSAFE bool
|
||||
}
|
||||
|
||||
// Start our migrations.
|
||||
// NOTE: This is only to be ran after GORM's AutoMigrate.
|
||||
func Now(db *gorm.DB) error {
|
||||
slog.Info("Starting migrations.")
|
||||
|
||||
for _, mig := range migrations {
|
||||
slog.Debug("Processing migration.", "id", mig.ID)
|
||||
|
||||
migRecord := MigrationRecord{ID: mig.ID}
|
||||
|
||||
// First ensure that the migration hasn't already been applied.
|
||||
var alreadyApplied int64
|
||||
res := db.
|
||||
Model(&MigrationRecord{}).
|
||||
Where(&migRecord).
|
||||
Count(&alreadyApplied)
|
||||
if res.Error != nil {
|
||||
slog.Error("already applied check failed!")
|
||||
return res.Error
|
||||
}
|
||||
if alreadyApplied > 0 {
|
||||
// If record exists in our table, then migration was applied
|
||||
// already, so skip processing it.
|
||||
slog.Debug("Migration already applied.", "id", mig.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Migration has NOT been applied before.. applying.",
|
||||
"id", mig.ID)
|
||||
|
||||
// Timing the migration.
|
||||
timeBeforeMig := time.Now()
|
||||
|
||||
// Apply the migration.
|
||||
if mig.UNSAFE {
|
||||
// Unsafe migrations are not ran inside of a transaction
|
||||
// and are only used when required by sqlite engine.
|
||||
if err := mig.Up(db); err != nil {
|
||||
slog.Error("Migration failed!", "id", mig.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
// Record the migration record.
|
||||
if res := db.Create(&migRecord); res.Error != nil {
|
||||
slog.Error("Unsafe migration succeeded, but we failed to create the record of it!",
|
||||
"id", mig.ID, "error", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
} else {
|
||||
// Migrations go through a transaction wrapper.
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := mig.Up(tx); err != nil {
|
||||
// Errored.. rollback any changes made.
|
||||
return err
|
||||
}
|
||||
// Migration succeeded.. record it.
|
||||
// If the Create succeeds, all will be committed.
|
||||
return tx.Create(&migRecord).Error
|
||||
})
|
||||
if err != nil {
|
||||
// If any migration fails, we return here.
|
||||
slog.Error("Migration failed!", "id", mig.ID, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("Migration applied successfully.",
|
||||
"id", mig.ID,
|
||||
"duration", time.Since(timeBeforeMig))
|
||||
}
|
||||
|
||||
slog.Info("Done processing all migrations.")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NOTE: For obvious reasons, once a migration is created and in production,
|
||||
// it is set in stone, so there should be almost no reason to change an existing
|
||||
// migration, create a new one instead!
|
||||
// If it's not obvious, changing an existing migration won't apply for people
|
||||
// who already have applied it and only apply for people who haven't yet,
|
||||
// so we are risking splitting the consistency of everyones databases as a
|
||||
// whole. I can't forsee any circumstance that would require doing so..
|
||||
|
||||
var migrations = []Migration{
|
||||
{
|
||||
// Backfilling `plays` data from users Activity.
|
||||
// (we have just created the 'count_as_play' column, instead of starting
|
||||
// existing data from 0 plays, we can check what existing activities
|
||||
// should count, and count them).
|
||||
ID: "202603201715_0001",
|
||||
Up: func(tx *gorm.DB) error {
|
||||
migID := "202603201715_0001"
|
||||
slog.Info("Migration is starting.", "mig", migID)
|
||||
// For ADDED_WATCHED and STATUS_CHANGED activities where the data
|
||||
// holds something saying FINISHED somewhere, count as a play.
|
||||
res := tx.
|
||||
Model(&entity.Activity{}).
|
||||
Where(
|
||||
`type IN ? AND data LIKE "%FINISHED%"`,
|
||||
[]entity.ActivityType{
|
||||
entity.ADDED_WATCHED,
|
||||
entity.STATUS_CHANGED,
|
||||
},
|
||||
).
|
||||
Update("count_as_play", 1)
|
||||
if res.Error != nil {
|
||||
slog.Error("First step failed!", "mig", migID,
|
||||
"error", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("First step succeeded, continuing.", "mig", migID)
|
||||
// For IMPORTED_ADDED_WATCHED* activities, we don't need to check
|
||||
// data (since there isn't any). We know these should always count
|
||||
// as a play, so count them.
|
||||
res = tx.
|
||||
Model(&entity.Activity{}).
|
||||
Where("type IN ?", []entity.ActivityType{
|
||||
entity.IMPORTED_ADDED_WATCHED,
|
||||
entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
}).
|
||||
Update("count_as_play", 1)
|
||||
if res.Error != nil {
|
||||
slog.Error("Second step failed!", "mig", migID,
|
||||
"error", res.Error)
|
||||
return res.Error
|
||||
}
|
||||
slog.Info("Second step succeeded, continuing.", "mig", migID)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// Dropping `deleted_at` columns for `watched_seasons` and
|
||||
// `watched_episodes` tables since we do not use them.
|
||||
ID: "202604142234_0002",
|
||||
Up: func(tx *gorm.DB) error {
|
||||
migID := "202604142234_0002"
|
||||
slog.Info("Migration is starting.", "mig", migID)
|
||||
|
||||
// Drop deleted_at for watched_seasons
|
||||
err := tx.Migrator().DropColumn(&entity.WatchedSeason{}, "deleted_at")
|
||||
if err != nil {
|
||||
slog.Error("watched_seasons migration failed!", "mig", migID)
|
||||
return err
|
||||
}
|
||||
slog.Info("watched_seasons migration succeeded.", "mig", migID)
|
||||
|
||||
// Drop deleted_at for watched_episodes
|
||||
err = tx.Migrator().DropColumn(&entity.WatchedEpisode{}, "deleted_at")
|
||||
if err != nil {
|
||||
slog.Error("watched_episodes migration failed!", "mig", migID)
|
||||
return err
|
||||
}
|
||||
slog.Info("watched_episodes migration succeeded.", "mig", migID)
|
||||
|
||||
slog.Info("Migration complete.", "mig", migID)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
// Moving to using WAL journal_mode for our sqlite database, which
|
||||
// should grant us improvements in all areas.
|
||||
// https://sqlite.org/pragma.html#pragma_journal_mode
|
||||
ID: "202604162229_0003",
|
||||
// NOTE: We are using `unsafe` so that this migration isn't ran inside
|
||||
// of a transaction (can't change into WAL from within one), so we
|
||||
// MUST ENSURE we are only doing one thing!
|
||||
UNSAFE: true,
|
||||
Up: func(db *gorm.DB) error {
|
||||
migID := "202604162229_0003"
|
||||
slog.Info("Migration is starting.", "mig", migID)
|
||||
|
||||
var mode string
|
||||
res := db.Raw("PRAGMA journal_mode=WAL").Scan(&mode)
|
||||
if res.Error != nil {
|
||||
slog.Error("Setting journal_mode=WAL failed!", "mig", migID)
|
||||
return res.Error
|
||||
}
|
||||
// Setting journal_mode might not return an error if it fails,
|
||||
// it always returns the current journal_mode of the db, which
|
||||
// will be WAL if it succeeds OR the "old" journal_mode if it
|
||||
// wasn't changed.
|
||||
// If the mode returned isn't WAL, then something has failed, so
|
||||
// we'll error to stop here and prevent the migration record from
|
||||
// being created, allowing the user to try again.
|
||||
// Note: I was able to test this code by opening the db like this
|
||||
// `sqlite.Open("file:data/watcharr.db?immutable=true")` and
|
||||
// commenting out other code so we get right to this migration
|
||||
// without failing at AutoMigration, etc.
|
||||
slog.Info("journal_mode response.", "mode", mode)
|
||||
if strings.ToLower(mode) != "wal" {
|
||||
slog.Error("Setting journal_mode=WAL failed silently!")
|
||||
return errors.New("Database is not in WAL mode after setting journal_mode=WAL")
|
||||
}
|
||||
slog.Info("WAL journal_mode migration succeeded.", "mig", migID)
|
||||
|
||||
slog.Info("Migration complete.", "mig", migID)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import (
|
||||
)
|
||||
|
||||
type (
|
||||
ActivityAddRequest struct {
|
||||
// Internal struct accepted by AddActivity function.
|
||||
ActivityAddProps struct {
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type entity.ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
@@ -19,6 +20,22 @@ type (
|
||||
}
|
||||
|
||||
ActivityAddProvider interface {
|
||||
AddActivity(userId uint, ar ActivityAddRequest) (entity.Activity, error)
|
||||
AddActivity(
|
||||
userId uint,
|
||||
ar ActivityAddProps,
|
||||
countAsPlay bool,
|
||||
) (entity.Activity, error)
|
||||
}
|
||||
)
|
||||
|
||||
// Looks through Activity for Watched entry and calculates the amount
|
||||
// that count as plays.
|
||||
func getPlaysFromActivity(a []entity.Activity) int {
|
||||
plays := 0
|
||||
for i := range a {
|
||||
if a[i].CountAsPlay {
|
||||
plays++
|
||||
}
|
||||
}
|
||||
return plays
|
||||
}
|
||||
|
||||
@@ -63,6 +63,9 @@ type Media struct {
|
||||
// A link to the database we are using that lists all providers with max details.
|
||||
// (especially for TMDB since it's data from JustWatch isn't available to us).
|
||||
ProvidersFullListLink string `json:"providersFullListLink,omitempty"`
|
||||
// Status of the media (released, ended, etc).
|
||||
// Depending on media type, this will contain different values.
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
//
|
||||
// Properties only for movies/tv.
|
||||
@@ -78,6 +81,9 @@ type Media struct {
|
||||
// details for the server to fetch fully/verify, i.e fetched full details from
|
||||
// tmdb again to verify if show is anime itself, etc).
|
||||
IsShowAnime bool `json:"isShowAnime,omitempty"`
|
||||
// Last release date.
|
||||
// Currently used for tv shows so frontend can display its end date.
|
||||
ReleaseDateLast time.Time `json:"releaseDateLast,omitzero"`
|
||||
|
||||
//
|
||||
// Properties only for Games
|
||||
|
||||
@@ -89,6 +89,8 @@ type WatchedDto struct {
|
||||
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes,omitempty"`
|
||||
Tags []entity.Tag `json:"tags,omitempty"`
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
// Amount of plays this media has, calculated from activity.
|
||||
Plays int `json:"plays,omitempty"`
|
||||
}
|
||||
|
||||
// New dto with base properties that we have for all WatchedDtos.
|
||||
@@ -137,6 +139,7 @@ func NewWatchedDtoForContentPage(w *entity.Watched) WatchedDto {
|
||||
dto.WatchedEpisodes = w.WatchedEpisodes
|
||||
dto.Tags = w.Tags
|
||||
dto.LastViewedSeason = w.LastViewedSeason
|
||||
dto.Plays = getPlaysFromActivity(w.Activity)
|
||||
|
||||
return dto
|
||||
}
|
||||
@@ -192,11 +195,35 @@ type WatchedAddRequest struct {
|
||||
|
||||
// Update watched entry request
|
||||
type WatchedUpdateRequest struct {
|
||||
Status entity.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"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating"`
|
||||
Thoughts string `json:"thoughts" `
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
Pinned *bool `json:"pinned" `
|
||||
// Allow the added activity count as play?
|
||||
// If the activity was going to count, this can stop it.
|
||||
LetCountAsPlay *bool `json:"letCountAsPlay"`
|
||||
}
|
||||
|
||||
// If the struct is valid for the Update Request.
|
||||
// - has atleast one property set.
|
||||
// - If rating is set, can't be out of bounds.
|
||||
func (w WatchedUpdateRequest) Valid() error {
|
||||
if w.Status == "" &&
|
||||
w.Rating == 0 &&
|
||||
(w.Thoughts == "" && !w.RemoveThoughts) &&
|
||||
w.Pinned == nil &&
|
||||
w.LetCountAsPlay == nil {
|
||||
// No properties are set, so this struct is not valid.
|
||||
return errors.New("no properties provided")
|
||||
}
|
||||
if w.Status != "" && !w.Status.IsValid() {
|
||||
return errors.New("status is not set to a supported status")
|
||||
}
|
||||
if w.Rating < 0 || w.Rating > 10 {
|
||||
return errors.New("rating can only be a value from 0-10")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update response.
|
||||
|
||||
@@ -19,31 +19,61 @@ func NewService(db *gorm.DB) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetActivity(userId uint, watchedId uint) ([]entity.Activity, error) {
|
||||
func (s *Service) GetActivity(
|
||||
userId uint,
|
||||
watchedId uint,
|
||||
) ([]entity.Activity, error) {
|
||||
activity := new([]entity.Activity)
|
||||
res := s.db.Model(&entity.Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
|
||||
res := s.db.Model(&entity.Activity{}).
|
||||
Where("user_id = ? AND watched_id = ?", userId, watchedId).
|
||||
Find(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed getting activity from database", "error", res.Error.Error())
|
||||
slog.Error("Failed getting activity from database",
|
||||
"error", res.Error.Error())
|
||||
return []entity.Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddActivity(userId uint, ar domain.ActivityAddRequest) (entity.Activity, error) {
|
||||
// NOTE: Currently this function doesn't verify if `userId` owns the referenced
|
||||
// watched item at `ar.WatchedID`. If we ever need this function to work from an
|
||||
// "AddActivity" endpoint on the API, we should create another func that has
|
||||
// that validation, since this func is only for internal operations!
|
||||
// AddActivity: Only for internal use.
|
||||
func (s *Service) AddActivity(
|
||||
userId uint,
|
||||
ar domain.ActivityAddProps,
|
||||
// If this activity counts as a play.
|
||||
countAsPlay bool,
|
||||
) (entity.Activity, error) {
|
||||
if ar.WatchedID == 0 {
|
||||
return entity.Activity{}, errors.New("watchedId must be set to add an activity")
|
||||
return entity.Activity{},
|
||||
errors.New("watchedId must be set to add an activity")
|
||||
}
|
||||
activity := entity.Activity{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
Type: ar.Type,
|
||||
Data: ar.Data,
|
||||
CustomDate: ar.CustomDate,
|
||||
CountAsPlay: countAsPlay,
|
||||
}
|
||||
activity := entity.Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data, CustomDate: ar.CustomDate}
|
||||
res := s.db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding activity to database", "error", res.Error.Error())
|
||||
return entity.Activity{}, errors.New("failed adding new activity to database")
|
||||
slog.Error("Error adding activity to database",
|
||||
"error", res.Error.Error())
|
||||
return entity.Activity{},
|
||||
errors.New("failed adding new activity to database")
|
||||
}
|
||||
slog.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateActivity(userId uint, id uint, activityUpdateRequest domain.ActivityUpdateRequest) error {
|
||||
func (s *Service) UpdateActivity(
|
||||
userId uint,
|
||||
id uint,
|
||||
activityUpdateRequest domain.ActivityUpdateRequest,
|
||||
) error {
|
||||
if id == 0 {
|
||||
return errors.New("id must be set to update an activity")
|
||||
}
|
||||
@@ -55,11 +85,12 @@ func (s *Service) UpdateActivity(userId uint, id uint, activityUpdateRequest dom
|
||||
Where("user_id = ? AND id = ?", userId, id).
|
||||
Update("custom_date", activityUpdateRequest.CustomDate)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating activity in database", "error", res.Error.Error())
|
||||
slog.Error("Error updating activity in database",
|
||||
"error", res.Error.Error())
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
slog.Error("No activities were updated.")
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
slog.Debug("Updating activity", "updated_activity", id)
|
||||
@@ -72,11 +103,12 @@ func (s *Service) DeleteActivity(userId uint, id uint) error {
|
||||
}
|
||||
res := s.db.Where("user_id = ?", userId).Delete(&entity.Activity{}, id)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error deleting activity in database", "error", res.Error.Error())
|
||||
slog.Error("Error deleting activity in database",
|
||||
"error", res.Error.Error())
|
||||
return errors.New("failed deleting activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
slog.Error("No activities were deleted.")
|
||||
return errors.New("failed deleting activity from database")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -27,7 +27,6 @@ func (r *Router) AddRoutes() {
|
||||
activity := r.br.Router.Group("/activity").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
activity.GET(":watchedId", r.GetActivity)
|
||||
activity.POST("", r.AddActivity)
|
||||
activity.PUT(":id", r.UpdateActivity)
|
||||
activity.DELETE(":id", r.DeleteActivity)
|
||||
}
|
||||
@@ -47,22 +46,6 @@ func (r *Router) GetActivity(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
func (r *Router) AddActivity(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar domain.ActivityAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.service.AddActivity(userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) UpdateActivity(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
@@ -14,6 +14,10 @@ import (
|
||||
|
||||
// Auth middleware
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
//
|
||||
// **NOTE:** Instead of providing the `db` parameter, it is probably better to
|
||||
// fetch what you need in the handler directly! We might follow that pattern
|
||||
// from now on and potentially remove `db` from this func in the future.
|
||||
func AuthRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("AuthRequired middleware hit")
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -13,7 +12,6 @@ import (
|
||||
"time"
|
||||
|
||||
gocache "github.com/robfig/go-cache"
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
@@ -160,8 +158,11 @@ func (s *Service) saveContent(c *entity.Content, onlyUpdate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) cacheContentTv(content tmdb.TMDBShowDetails, onlyUpdate bool) (entity.Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
func (s *Service) CacheContentShow(
|
||||
content tmdb.ShowDetails,
|
||||
onlyUpdate bool,
|
||||
) (entity.Content, error) {
|
||||
slog.Debug("CacheContentShow", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
@@ -193,14 +194,17 @@ func (s *Service) cacheContentTv(content tmdb.TMDBShowDetails, onlyUpdate bool)
|
||||
|
||||
err = s.saveContent(&c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
slog.Error("CacheContentShow: Failed to save content!", "error", err)
|
||||
return entity.Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) cacheContentMovie(content tmdb.TMDBMovieDetails, onlyUpdate bool) (entity.Content, error) {
|
||||
func (s *Service) CacheContentMovie(
|
||||
content tmdb.MovieDetails,
|
||||
onlyUpdate bool,
|
||||
) (entity.Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
@@ -238,7 +242,10 @@ func (s *Service) cacheContentMovie(content tmdb.TMDBMovieDetails, onlyUpdate bo
|
||||
}
|
||||
|
||||
// Get content from our db cache, or cache it if it doesn't exist.
|
||||
func (s *Service) GetOrCacheContent(contentType entity.ContentType, tmdbId int) (entity.Content, error) {
|
||||
func (s *Service) GetOrCacheContent(
|
||||
contentType entity.ContentType,
|
||||
tmdbId int,
|
||||
) (entity.Content, error) {
|
||||
var content entity.Content
|
||||
// Look in db for content.
|
||||
s.db.Where("type = ? AND tmdb_id = ?", contentType, tmdbId).Find(&content)
|
||||
@@ -246,407 +253,51 @@ func (s *Service) GetOrCacheContent(contentType entity.ContentType, tmdbId int)
|
||||
if content == (entity.Content{}) {
|
||||
slog.Debug("Content not in db, fetching...", "type", contentType, "tmdbId", tmdbId)
|
||||
|
||||
resp, err := s.tmdb.APIRequest("/"+string(contentType)+"/"+strconv.Itoa(tmdbId), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: content tmdb api request failed", "error", err)
|
||||
return entity.Content{}, errors.New("failed to find requested media")
|
||||
tmdbId := strconv.Itoa(tmdbId)
|
||||
|
||||
switch contentType {
|
||||
case entity.MOVIE:
|
||||
resp, err := s.tmdb.MovieDetails(tmdb.MovieDetailsOptions{
|
||||
ID: tmdbId,
|
||||
DontRunDBCache: true,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: MovieDetails failed.",
|
||||
"content_id", tmdbId,
|
||||
"err", err)
|
||||
return entity.Content{}, errors.New("details request failed")
|
||||
}
|
||||
content, err = s.CacheContentMovie(resp, false)
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: Caching movie failed",
|
||||
"content_id", tmdbId,
|
||||
"err", err)
|
||||
return entity.Content{}, errors.New("caching failed")
|
||||
}
|
||||
case entity.SHOW:
|
||||
resp, err := s.tmdb.ShowDetails(tmdb.ShowDetailsOptions{
|
||||
ID: tmdbId,
|
||||
DontRunDBCache: true,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: ShowDetails failed.",
|
||||
"content_id", tmdbId,
|
||||
"err", err)
|
||||
}
|
||||
content, err = s.CacheContentShow(resp, false)
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: Caching show failed",
|
||||
"content_id", tmdbId,
|
||||
"err", err)
|
||||
return entity.Content{}, errors.New("caching failed")
|
||||
}
|
||||
default:
|
||||
slog.Error("GetOrCacheContent: Unsupported contentType",
|
||||
"type", contentType,
|
||||
"content_id", tmdbId)
|
||||
return entity.Content{}, errors.New("unsupported contentType")
|
||||
}
|
||||
|
||||
if contentType == "movie" {
|
||||
c := new(tmdb.TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return entity.Content{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = s.cacheContentMovie(*c, false)
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: failed to cache movie content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return entity.Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
} else {
|
||||
c := new(tmdb.TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return entity.Content{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = s.cacheContentTv(*c, false)
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: failed to cache tv content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return entity.Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// TMDB Multi Search.
|
||||
func (s *Service) SearchContent(query string, pageNum int) (tmdb.TMDBSearchMultiResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchMultiResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchContent", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchContent: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/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 tmdb.TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchMovies(query string, pageNum int) (tmdb.TMDBSearchMoviesResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchMoviesResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchMovies", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/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 tmdb.TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchTv(query string, pageNum int) (tmdb.TMDBSearchShowsResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchShowsResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchTv", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/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 tmdb.TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchPeople(query string, pageNum int) (tmdb.TMDBSearchPeopleResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchPeopleResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchPeople", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchPeople: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/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 tmdb.TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "person"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Search for content by an external id (imdb, etc).
|
||||
// Defaults to imdb if no source if provided (probably most common).
|
||||
func (s *Service) SearchByExternalId(id string, source string) (tmdb.TMDBSearchMultiResponse, error) {
|
||||
resp := new(tmdb.TMDBFindByExternalIdResponse)
|
||||
if source == "" {
|
||||
source = "imdb"
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchByExternalId", id, source)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchByExternalId: Got cache.")
|
||||
} else {
|
||||
// If not found in cache, request data from tmdb.
|
||||
err := s.tmdb.Request("/find/"+id, map[string]string{"external_source": source + "_id"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete find/external_id request!", "error", err.Error())
|
||||
return tmdb.TMDBSearchMultiResponse{}, errors.New("failed to complete find/external_id request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
}
|
||||
comb := []tmdb.TMDBSearchMultiResult{}
|
||||
comb = append(comb, resp.MovieResults...)
|
||||
comb = append(comb, resp.TvResults...)
|
||||
comb = append(comb, resp.PersonResults...)
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return tmdb.TMDBSearchMultiResponse{TMDBSearchResponse: tmdb.TMDBSearchResponse[tmdb.TMDBSearchMultiResult]{
|
||||
Results: comb,
|
||||
TMDBPageFields: tmdb.TMDBPageFields{
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MovieDetails(
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (tmdb.TMDBMovieDetails, error) {
|
||||
resp := new(tmdb.TMDBMovieDetails)
|
||||
cacheKey := cache.CreateCacheKey("MovieDetails", id, country, rParams)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("MovieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie details request!",
|
||||
"error", err.Error())
|
||||
return tmdb.TMDBMovieDetails{},
|
||||
errors.New("failed to complete movie details request")
|
||||
}
|
||||
resp.WatchProvidersTransformed = transformProviders(&resp.WatchProviders, country)
|
||||
resp.WatchProviders = nil // We don't want this to linger around (in cache) since we have the transformed version now..
|
||||
go s.cacheContentMovie(*resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) MovieCredits(id string) (tmdb.TMDBContentCredits, error) {
|
||||
resp := new(tmdb.TMDBContentCredits)
|
||||
err := s.tmdb.Request("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie cast request!", "error", err.Error())
|
||||
return tmdb.TMDBContentCredits{}, errors.New("failed to complete movie cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) TvDetails(
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (tmdb.TMDBShowDetails, error) {
|
||||
cacheKey := cache.CreateCacheKey("TvDetails", id, country, rParams)
|
||||
resp := new(tmdb.TMDBShowDetails)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("TvDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
return tmdb.TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
resp.WatchProvidersTransformed = transformProviders(&resp.WatchProviders, country)
|
||||
resp.WatchProviders = nil // We don't want this to linger around (in cache) since we have the transformed version now..
|
||||
go s.cacheContentTv(*resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) TvCredits(id string) (tmdb.TMDBContentCredits, error) {
|
||||
resp := new(tmdb.TMDBContentCredits)
|
||||
err := s.tmdb.Request("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv cast request!", "error", err.Error())
|
||||
return tmdb.TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func (s *Service) SeasonDetails(tvId string, seasonNumber string) (tmdb.TMDBSeasonDetails, error) {
|
||||
cacheKey := cache.CreateCacheKey("SeasonDetails", tvId, seasonNumber)
|
||||
resp := new(tmdb.TMDBSeasonDetails)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SeasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("SeasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return tmdb.TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) PersonDetails(id string) (tmdb.TMDBPersonDetails, error) {
|
||||
resp := new(tmdb.TMDBPersonDetails)
|
||||
err := s.tmdb.Request("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return tmdb.TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) PersonCredits(id string) (tmdb.TMDBPersonCombinedCredits, error) {
|
||||
cacheKey := cache.CreateCacheKey("PersonCredits", id)
|
||||
resp := new(tmdb.TMDBPersonCombinedCredits)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("PersonCredits: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return tmdb.TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Trending(t tmdb.TrendingType, pageNum int, region string) (tmdb.TMDBTrendingCombined, error) {
|
||||
resp := new(tmdb.TMDBTrendingCombined)
|
||||
if t != tmdb.TrendingTypeAll &&
|
||||
t != tmdb.TrendingTypeMovie &&
|
||||
t != tmdb.TrendingTypeShow &&
|
||||
t != tmdb.TrendingTypePerson {
|
||||
slog.Error("Trending: Invalid type provided", "provided_t", t)
|
||||
return *resp, errors.New("invalid type")
|
||||
}
|
||||
if pageNum <= 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("Trending", string(t), region, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("Trending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/trending/"+string(t)+"/day", map[string]string{
|
||||
"page": strconv.Itoa(pageNum),
|
||||
"region": region,
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return *resp, errors.New("failed to complete all trending request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) DiscoverMovies(
|
||||
o tmdb.DiscoverOptions,
|
||||
pageNum int,
|
||||
region string,
|
||||
) (tmdb.TMDBDiscoverMovies, error) {
|
||||
resp := new(tmdb.TMDBDiscoverMovies)
|
||||
reqParams := map[string]string{
|
||||
"page": strconv.Itoa(pageNum),
|
||||
"region": region,
|
||||
}
|
||||
s.applyDiscoverOptionsToMap(true, o, reqParams)
|
||||
cacheKey := cache.CreateCacheKey("DiscoverMovies", pageNum, reqParams)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("DiscoverMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/discover/movie", reqParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("DiscoverMovies: Failed to complete request!", "error", err.Error())
|
||||
return tmdb.TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) DiscoverTv(
|
||||
o tmdb.DiscoverOptions,
|
||||
pageNum int,
|
||||
region string,
|
||||
) (tmdb.TMDBDiscoverShows, error) {
|
||||
resp := new(tmdb.TMDBDiscoverShows)
|
||||
reqParams := map[string]string{
|
||||
"page": strconv.Itoa(pageNum),
|
||||
"region": region,
|
||||
}
|
||||
s.applyDiscoverOptionsToMap(false, o, reqParams)
|
||||
cacheKey := cache.CreateCacheKey("DiscoverTv", pageNum, reqParams)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("DiscoverTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/discover/tv", reqParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("DiscoverTv: Failed to complete request!", "error", err.Error())
|
||||
return tmdb.TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) applyDiscoverOptionsToMap(
|
||||
// Some properties are named differently for sorting the same thing as far
|
||||
// as we care, so we need to differenciate to name them properly.
|
||||
forMovie bool,
|
||||
o tmdb.DiscoverOptions,
|
||||
m map[string]string,
|
||||
) {
|
||||
releaseDateMinKey := "release_date.gte"
|
||||
releaseDateMaxKey := "release_date.lte"
|
||||
withReleaseTypeKey := "with_release_type"
|
||||
if !forMovie {
|
||||
// Replace with names for equivalent tv filters
|
||||
releaseDateMinKey = "first_air_date.gte"
|
||||
releaseDateMaxKey = "first_air_date.lte"
|
||||
withReleaseTypeKey = "with_type"
|
||||
}
|
||||
if !o.ReleaseDateMin.IsZero() {
|
||||
m[releaseDateMinKey] = o.ReleaseDateMin.Format("2006-01-02")
|
||||
}
|
||||
if !o.ReleaseDateMax.IsZero() {
|
||||
m[releaseDateMaxKey] = o.ReleaseDateMax.Format("2006-01-02")
|
||||
}
|
||||
if o.WithReleaseType != "" {
|
||||
m[withReleaseTypeKey] = o.WithReleaseType
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) PopularPeople(pageNum int) (tmdb.TMDBPopularPeople, error) {
|
||||
cacheKey := cache.CreateCacheKey("PopularPeople", pageNum)
|
||||
resp := new(tmdb.TMDBPopularPeople)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("PopularPeople: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.tmdb.Request("/person/popular",
|
||||
map[string]string{"page": strconv.Itoa(pageNum)},
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("PopularPeople: Failed to complete request!", "error", err.Error())
|
||||
return tmdb.TMDBPopularPeople{}, errors.New("failed to complete request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Regions() (tmdb.TMDBRegions, error) {
|
||||
resp := new(tmdb.TMDBRegions)
|
||||
err := s.tmdb.Request("/watch/providers/regions", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete regions request!", "error", err.Error())
|
||||
return tmdb.TMDBRegions{}, errors.New("failed to complete regions request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/sbondCo/Watcharr/domain"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/addedtocontent"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
@@ -23,16 +24,23 @@ type WatchedProvider interface {
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
cs *Service
|
||||
wp WatchedProvider
|
||||
br *router.BaseRouter
|
||||
cs *Service
|
||||
wp WatchedProvider
|
||||
tmdb *tmdb.TMDB
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter, cs *Service, wp WatchedProvider) *Router {
|
||||
func NewRouter(
|
||||
br *router.BaseRouter,
|
||||
cs *Service,
|
||||
wp WatchedProvider,
|
||||
tmdb *tmdb.TMDB,
|
||||
) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
cs: cs,
|
||||
wp: wp,
|
||||
br: br,
|
||||
cs: cs,
|
||||
wp: wp,
|
||||
tmdb: tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,13 +76,13 @@ func (r *Router) GetMovieDetails(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := r.cs.MovieDetails(
|
||||
c.Param("id"),
|
||||
c.MustGet("userCountry").(string),
|
||||
map[string]string{
|
||||
content, err := r.tmdb.MovieDetails(tmdb.MovieDetailsOptions{
|
||||
ID: c.Param("id"),
|
||||
Country: c.MustGet("userCountry").(string),
|
||||
Params: map[string]string{
|
||||
"append_to_response": "videos,watch/providers,similar",
|
||||
},
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -111,7 +119,7 @@ func (r *Router) GetMovieCredits(c *gin.Context) {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.MovieCredits(c.Param("id"))
|
||||
content, err := r.tmdb.MovieCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -126,13 +134,13 @@ func (r *Router) GetTvDetails(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
// 1. Get details
|
||||
content, err := r.cs.TvDetails(
|
||||
c.Param("id"),
|
||||
c.MustGet("userCountry").(string),
|
||||
map[string]string{
|
||||
content, err := r.tmdb.ShowDetails(tmdb.ShowDetailsOptions{
|
||||
ID: c.Param("id"),
|
||||
Country: c.MustGet("userCountry").(string),
|
||||
Params: map[string]string{
|
||||
"append_to_response": "videos,watch/providers,similar,external_ids,keywords",
|
||||
},
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -169,7 +177,7 @@ func (r *Router) GetTvCredits(c *gin.Context) {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.TvCredits(c.Param("id"))
|
||||
content, err := r.tmdb.ShowCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -184,7 +192,7 @@ func (r *Router) GetSeasonDetails(c *gin.Context) {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.SeasonDetails(c.Param("id"), c.Param("num"))
|
||||
content, err := r.tmdb.SeasonDetails(c.Param("id"), c.Param("num"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -221,7 +229,7 @@ func (r *Router) GetPerson(c *gin.Context) {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.PersonDetails(c.Param("id"))
|
||||
content, err := r.tmdb.PersonDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -235,7 +243,7 @@ func (r *Router) GetPersonCredits(c *gin.Context) {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.PersonCredits(c.Param("id"))
|
||||
content, err := r.tmdb.PersonCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
@@ -264,7 +272,7 @@ func (r *Router) GetPersonCredits(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (r *Router) GetRegions(c *gin.Context) {
|
||||
re, err := r.cs.Regions()
|
||||
re, err := r.tmdb.Regions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
|
||||
@@ -11,28 +11,21 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ContentProvider interface {
|
||||
Trending(t tmdb.TrendingType, pageNum int, region string) (tmdb.TMDBTrendingCombined, error)
|
||||
DiscoverMovies(o tmdb.DiscoverOptions, pageNum int, region string) (tmdb.TMDBDiscoverMovies, error)
|
||||
DiscoverTv(o tmdb.DiscoverOptions, pageNum int, region string) (tmdb.TMDBDiscoverShows, error)
|
||||
PopularPeople(pageNum int) (tmdb.TMDBPopularPeople, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
cfg *config.ServerConfig
|
||||
contentProvider ContentProvider
|
||||
db *gorm.DB
|
||||
cfg *config.ServerConfig
|
||||
tmdb *tmdb.TMDB
|
||||
}
|
||||
|
||||
func NewService(
|
||||
db *gorm.DB,
|
||||
cfg *config.ServerConfig,
|
||||
contentProvider ContentProvider,
|
||||
tmdb *tmdb.TMDB,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db,
|
||||
cfg,
|
||||
contentProvider,
|
||||
tmdb,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +160,7 @@ func (s *Service) discoverMultiTrending(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.Trending(t, meta.PageParams.Page, meta.Region)
|
||||
tmdbRes, err := s.tmdb.Trending(t, meta.PageParams.Page, meta.Region)
|
||||
if err != nil {
|
||||
slog.Error("discoverMulti: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -188,7 +181,7 @@ func (s *Service) discoverMovieInTheatres(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.DiscoverMovies(
|
||||
tmdbRes, err := s.tmdb.DiscoverMovies(
|
||||
tmdb.DiscoverOptions{
|
||||
ReleaseDateMin: time.Now().AddDate(0, 0, -40),
|
||||
ReleaseDateMax: time.Now().AddDate(0, 0, 2),
|
||||
@@ -218,7 +211,7 @@ func (s *Service) discoverMovieUpcoming(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.DiscoverMovies(
|
||||
tmdbRes, err := s.tmdb.DiscoverMovies(
|
||||
tmdb.DiscoverOptions{
|
||||
ReleaseDateMin: time.Now(),
|
||||
ReleaseDateMax: time.Now().AddDate(0, 1, 0),
|
||||
@@ -248,7 +241,7 @@ func (s *Service) discoverMoviePopular(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.DiscoverMovies(
|
||||
tmdbRes, err := s.tmdb.DiscoverMovies(
|
||||
tmdb.DiscoverOptions{},
|
||||
meta.PageParams.Page,
|
||||
meta.Region,
|
||||
@@ -274,7 +267,7 @@ func (s *Service) discoverTvUpcoming(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.DiscoverTv(
|
||||
tmdbRes, err := s.tmdb.DiscoverShows(
|
||||
tmdb.DiscoverOptions{
|
||||
ReleaseDateMin: time.Now(),
|
||||
ReleaseDateMax: time.Now().AddDate(0, 1, 0),
|
||||
@@ -304,7 +297,7 @@ func (s *Service) discoverTvPopular(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.DiscoverTv(
|
||||
tmdbRes, err := s.tmdb.DiscoverShows(
|
||||
tmdb.DiscoverOptions{},
|
||||
meta.PageParams.Page,
|
||||
meta.Region,
|
||||
@@ -330,7 +323,7 @@ func (s *Service) discoverPeoplePopular(
|
||||
meta domain.DiscoverRequestMeta,
|
||||
resp *domain.DiscoverResponse,
|
||||
) error {
|
||||
tmdbRes, err := s.contentProvider.PopularPeople(
|
||||
tmdbRes, err := s.tmdb.PopularPeople(
|
||||
meta.PageParams.Page,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,7 +35,19 @@ func (s *Service) saveGame(c *entity.Game, onlyUpdate bool) error {
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := image.DownloadAndInsertImage(s.db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
p, err := image.
|
||||
NewSaver(
|
||||
s.db,
|
||||
"games",
|
||||
image.ValidateOptions{
|
||||
// To avoid losing quality, we want to keep png format
|
||||
// for our game posters.
|
||||
ToFormat: image.ValidateAllowedFormatPNG,
|
||||
},
|
||||
).
|
||||
DownloadAndInsertFromUrl(
|
||||
"https://images.igdb.com/igdb/image/upload/t_cover_big/" +
|
||||
c.CoverID + ".png")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// This router simply serves the images stored in the server data folder
|
||||
// under the `img` folder.
|
||||
// Note: The `img` folder contains user uploaded content (eg profile pictures).
|
||||
|
||||
package img
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{
|
||||
br,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
img := r.br.Router.Group("/img").
|
||||
Use(func(c *gin.Context) {
|
||||
// The two following headers are preventative since this group
|
||||
// (the static route below) hosts user uploaded content, which can
|
||||
// potentially include malicious data. We are trying to protect
|
||||
// against XSS attacks here by telling the browser to:
|
||||
// - Not sniff content; and
|
||||
// - Not execute JS; and
|
||||
// - treat the content as if it was a separate domain (so if eg
|
||||
// somehow js runs, it won't be in same context as our tokens).
|
||||
// RESOURCE: web.dev/articles/securely-hosting-user-data
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; sandbox")
|
||||
c.Next()
|
||||
})
|
||||
|
||||
// Serve up img folder.
|
||||
img.Static("/", path.Join(config.DataPath, "img"))
|
||||
}
|
||||
@@ -30,10 +30,6 @@ type WatchedEpisodeProvider interface {
|
||||
AddWatchedEpisodes(userId uint, ar episode.WatchedEpisodeAddRequest) (episode.WatchedEpisodeAddResponse, error)
|
||||
}
|
||||
|
||||
type ContentProvider interface {
|
||||
SearchByExternalId(id string, source string) (tmdb.TMDBSearchMultiResponse, error)
|
||||
}
|
||||
|
||||
type TagProvider interface {
|
||||
AddTag(userId uint, tr domain.TagAddRequest) (entity.Tag, error)
|
||||
GetTagByNameAndColor(userId uint, tagName string, tagColor string, tagBgColor string) (entity.Tag, error)
|
||||
@@ -48,7 +44,7 @@ type Service struct {
|
||||
wp WatchedProvider
|
||||
wsp WatchedSeasonProvider
|
||||
wep WatchedEpisodeProvider
|
||||
cp ContentProvider
|
||||
tmdb *tmdb.TMDB
|
||||
activityProvider domain.ActivityAddProvider
|
||||
tagProvider TagProvider
|
||||
searchProvider SearchProvider
|
||||
@@ -59,7 +55,7 @@ func NewService(
|
||||
wp WatchedProvider,
|
||||
wsp WatchedSeasonProvider,
|
||||
wep WatchedEpisodeProvider,
|
||||
cp ContentProvider,
|
||||
tmdb *tmdb.TMDB,
|
||||
activityProvider domain.ActivityAddProvider,
|
||||
tagProvider TagProvider,
|
||||
searchProvider SearchProvider,
|
||||
@@ -69,7 +65,7 @@ func NewService(
|
||||
wp,
|
||||
wsp,
|
||||
wep,
|
||||
cp,
|
||||
tmdb,
|
||||
activityProvider,
|
||||
tagProvider,
|
||||
searchProvider,
|
||||
@@ -170,10 +166,12 @@ func (s *Service) SuccessfulImport(
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrWatchedExists) {
|
||||
slog.Error("successfulImport: Must already be on watch list", "error", err)
|
||||
slog.Error("successfulImport: Must already be on watch list",
|
||||
"error", err)
|
||||
return domain.ImportResponse{Type: domain.IMPORT_EXISTS}
|
||||
}
|
||||
slog.Error("successfulImport: Failed to add content as watched", "error", err)
|
||||
slog.Error("successfulImport: Failed to add content as watched",
|
||||
"error", err)
|
||||
return domain.ImportResponse{Type: domain.IMPORT_FAILED}
|
||||
}
|
||||
// Add activity of the original time the show was added to the users
|
||||
@@ -185,21 +183,58 @@ func (s *Service) SuccessfulImport(
|
||||
"rating": ar.Rating,
|
||||
"linkedActivity": w.Activity[0].ID,
|
||||
})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_RATING,
|
||||
Data: string(activityJson),
|
||||
CustomDate: ar.RatingCustomDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_RATING,
|
||||
Data: strconv.Itoa(int(ar.Rating)),
|
||||
CustomDate: ar.RatingCustomDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
}
|
||||
// Add all dates watched as activity, if any
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, v := range ar.DatesWatched {
|
||||
for i, v := range ar.DatesWatched {
|
||||
countAsPlay := true
|
||||
if i == 0 && ar.Status == entity.FINISHED {
|
||||
// If the watched status we are importing is of FINISHED
|
||||
// then the first DatesWatched must not count as a play,
|
||||
// since the import activity (set in AddWatched) will already.
|
||||
// Any subsequent DatesWatched should count as a play though.
|
||||
countAsPlay = false
|
||||
slog.Info("successfulImport: Set countAsPlay=false for first" +
|
||||
"DatesWatched to avoid duplicate play count with AddWatched activity.")
|
||||
}
|
||||
customDate := v
|
||||
addedActivity, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
addedActivity, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED,
|
||||
CustomDate: &customDate,
|
||||
},
|
||||
countAsPlay,
|
||||
)
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.",
|
||||
"date", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,11 +247,21 @@ func (s *Service) SuccessfulImport(
|
||||
if activityDate == nil || activityDate.IsZero() {
|
||||
activityDate = &ar.Activity[i].CreatedAt
|
||||
}
|
||||
addedActivity, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
|
||||
addedActivity, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: v.Type,
|
||||
Data: v.Data,
|
||||
CustomDate: activityDate,
|
||||
},
|
||||
v.CountAsPlay,
|
||||
)
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add imported activity.", "full_object", v, "error", err)
|
||||
slog.Error("successfulImport: Failed to add imported activity.",
|
||||
"full_object", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,7 +277,8 @@ func (s *Service) SuccessfulImport(
|
||||
AddActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched season.", "error", err)
|
||||
slog.Error("successfulImport: Failed to add watched season.",
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
w.WatchedSeasons = ws.WatchedSeasons
|
||||
@@ -251,7 +297,8 @@ func (s *Service) SuccessfulImport(
|
||||
AddActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched episodes.", "error", err)
|
||||
slog.Error("successfulImport: Failed to add watched episodes.",
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
w.WatchedEpisodes = ws.WatchedEpisodes
|
||||
@@ -266,7 +313,8 @@ func (s *Service) SuccessfulImport(
|
||||
var t entity.Tag
|
||||
t, err := s.tagProvider.GetTagByNameAndColor(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)
|
||||
slog.Error("successfulImport: Failed to check for an existing tag",
|
||||
"name", v.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
if t.ID == 0 {
|
||||
@@ -276,7 +324,8 @@ func (s *Service) SuccessfulImport(
|
||||
BgColor: v.BgColor,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add a tag.", "name", v.Name, "error", err)
|
||||
slog.Error("successfulImport: Failed to add a tag.",
|
||||
"name", v.Name, "error", err)
|
||||
continue
|
||||
}
|
||||
t = tag
|
||||
@@ -285,7 +334,8 @@ func (s *Service) SuccessfulImport(
|
||||
// Associate the watched entry with the tag
|
||||
err = watched.AddWatchedToTag(s.db, userId, t.ID, w.ID)
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.",
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
w.Tags = append(w.Tags, t)
|
||||
|
||||
@@ -257,7 +257,7 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: entity.PLANNED,
|
||||
GormModel: dbmodel.GormModel{
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
})
|
||||
@@ -283,7 +283,7 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: entity.PLANNED,
|
||||
GormModel: dbmodel.GormModel{
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
}}
|
||||
@@ -393,7 +393,7 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: entity.FINISHED,
|
||||
// Rating: ,
|
||||
GormModel: dbmodel.GormModel{
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{
|
||||
CreatedAt: v.WatchedAt,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ func (s *Service) importWithIMDBID(
|
||||
userId uint,
|
||||
ar *domain.ImportRequest,
|
||||
) (domain.ImportResponse, error) {
|
||||
if imdbResp, err := s.cp.SearchByExternalId(ar.ImdbID, "imdb"); err == nil {
|
||||
if imdbResp, err := s.tmdb.SearchByExternalId(ar.ImdbID, "imdb"); err == nil {
|
||||
if len(imdbResp.Results) == 1 {
|
||||
onlyResult := imdbResp.Results[0]
|
||||
if onlyResult.MediaType == string(entity.MOVIE) || onlyResult.MediaType == string(entity.SHOW) {
|
||||
|
||||
@@ -175,7 +175,15 @@ func (s *SyncService) startJellyfinSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
CustomDate: &v.UserData.LastPlayedDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
|
||||
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
@@ -280,10 +288,22 @@ func (s *SyncService) startJellyfinSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
CustomDate: &v.UserData.LastPlayedDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
|
||||
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.",
|
||||
"series_name", v.Name,
|
||||
"series_ids", v.ProviderIds,
|
||||
"user_id", userId,
|
||||
"date", v.UserData.LastPlayedDate,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,6 +334,27 @@ type PlexClientResources []struct {
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// plexHTTPClient is the shared client for all Plex outbound calls. Its
|
||||
// CheckRedirect policy strips the custom X-Plex-Token header when a redirect
|
||||
// crosses to a different host. net/http already strips the standard sensitive
|
||||
// headers (Authorization, Cookie, WWW-Authenticate) on a cross-host redirect,
|
||||
// but it does NOT strip custom-named headers, so without this the Plex token
|
||||
// would be forwarded to any host the configured PLEX_HOST redirects to.
|
||||
var plexHTTPClient = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return errors.New("stopped after 10 redirects")
|
||||
}
|
||||
if req.URL.Host != via[0].URL.Host {
|
||||
req.Header.Del("X-Plex-Token")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.ServerConfig
|
||||
}
|
||||
@@ -345,7 +366,7 @@ func NewService(cfg *config.ServerConfig) *Service {
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", host+"/identity", nil)
|
||||
if err != nil {
|
||||
return PlexIdentity{}, err
|
||||
@@ -369,7 +390,7 @@ func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
|
||||
}
|
||||
|
||||
func (s *Service) FetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
|
||||
if err != nil {
|
||||
return PlexUser{}, err
|
||||
@@ -419,7 +440,7 @@ func (s *Service) UpdateConfigPlexHost(cfg *config.ServerConfig, v string) (Plex
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections", nil)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
@@ -444,7 +465,7 @@ func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, erro
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
@@ -469,7 +490,7 @@ func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexL
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
@@ -494,7 +515,7 @@ func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (
|
||||
}
|
||||
|
||||
func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
@@ -522,7 +543,7 @@ func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string)
|
||||
// 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 (s *Service) GetPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
httpClient := plexHTTPClient
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package plex
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
)
|
||||
|
||||
// TestGetPlexLibraries_StripsTokenOnCrossHostRedirect verifies the Plex client
|
||||
// does not forward X-Plex-Token across a cross-host redirect but preserves it
|
||||
// on a same-host redirect.
|
||||
func TestGetPlexLibraries_StripsTokenOnCrossHostRedirect(t *testing.T) {
|
||||
const token = "secret-plex-token"
|
||||
|
||||
t.Run("cross-host strips token", func(t *testing.T) {
|
||||
var finalToken string
|
||||
final := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
finalToken = r.Header.Get("X-Plex-Token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"MediaContainer":{}}`))
|
||||
}))
|
||||
defer final.Close()
|
||||
redir := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, final.URL+r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
defer redir.Close()
|
||||
|
||||
svc := NewService(&config.ServerConfig{PLEX_HOST: redir.URL})
|
||||
_, _ = svc.GetPlexLibraries(token)
|
||||
if finalToken != "" {
|
||||
t.Fatalf("X-Plex-Token forwarded cross-host = %q, want empty", finalToken)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same-host keeps token", func(t *testing.T) {
|
||||
var finalToken string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("r") == "1" {
|
||||
finalToken = r.Header.Get("X-Plex-Token")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`{"MediaContainer":{}}`))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, r.URL.Path+"?r=1", http.StatusFound)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
svc := NewService(&config.ServerConfig{PLEX_HOST: srv.URL})
|
||||
_, _ = svc.GetPlexLibraries(token)
|
||||
if finalToken != token {
|
||||
t.Fatalf("X-Plex-Token on same host = %q, want %q", finalToken, token)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -150,11 +150,15 @@ func (s *SyncService) startPlexSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
},
|
||||
false,
|
||||
)
|
||||
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)
|
||||
@@ -236,11 +240,15 @@ func (s *SyncService) startPlexSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
},
|
||||
false,
|
||||
)
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -28,63 +27,38 @@ func NewService(db *gorm.DB) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
// Checks if item has been previously watched by scanning for any activity
|
||||
// that counts as a play.
|
||||
func (s *Service) hasBeenPreviouslyWatched(a *[]entity.Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []entity.Activity
|
||||
for _, v := range *a {
|
||||
if v.Type == entity.ADDED_WATCHED ||
|
||||
v.Type == entity.IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == entity.IMPORTED_WATCHED ||
|
||||
v.Type == entity.STATUS_CHANGED {
|
||||
relatedActivity = append(relatedActivity, v)
|
||||
if v.CountAsPlay {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(relatedActivity) <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, ra := range relatedActivity {
|
||||
if ra.Type == entity.IMPORTED_ADDED_WATCHED {
|
||||
wp = true
|
||||
break
|
||||
} else if ra.Type == entity.ADDED_WATCHED || ra.Type == entity.IMPORTED_WATCHED {
|
||||
if ra.Data == "" {
|
||||
continue
|
||||
}
|
||||
var v map[string]any
|
||||
err := json.Unmarshal([]byte(ra.Data), &v)
|
||||
if err != nil {
|
||||
slog.Error("Checking ADDED_WATCHED or IMPORTED_WATCHED.. failed to parse json data", "error", err)
|
||||
continue
|
||||
}
|
||||
if status, ok := v["status"]; ok {
|
||||
if status == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ra.Type == entity.STATUS_CHANGED {
|
||||
if ra.Data == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wp
|
||||
return false
|
||||
}
|
||||
|
||||
// Gets any data required for profile page
|
||||
func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
// Get user.
|
||||
user := new(entity.User)
|
||||
res := s.db.Model(&entity.User{}).Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get profile:", "error", res.Error.Error())
|
||||
slog.Error("Failed to get profile:",
|
||||
"error", res.Error)
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
|
||||
// Process stats.
|
||||
watched := new([]entity.Watched)
|
||||
res = s.db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
res = s.db.Model(&entity.Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Activity").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
slog.Error("Profile: Failed to get watched for processing:",
|
||||
"error", res.Error)
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
}
|
||||
var (
|
||||
@@ -95,11 +69,12 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
if w.Status == entity.FINISHED {
|
||||
isFinished = true
|
||||
} else if *user.IncludePreviouslyWatched && s.hasBeenPreviouslyWatched(&w.Activity) {
|
||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
||||
// then we can also check if content hasBeenPreviouslyWatched.
|
||||
// Note: Deliberately always checking `hasBeenPreviouslyWatched` for any
|
||||
// items without status set to FINISHED without checking users
|
||||
// `IncludePreviouslyWatched` setting, because that setting is useful
|
||||
// for filters, BUT not for these stats. I think it is always expected
|
||||
// that all previously watched stuff is included in finished stats.
|
||||
if w.Status == entity.FINISHED || s.hasBeenPreviouslyWatched(&w.Activity) {
|
||||
isFinished = true
|
||||
}
|
||||
if isFinished {
|
||||
@@ -107,7 +82,8 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
if c.Type == entity.SHOW {
|
||||
switch c.Type {
|
||||
case entity.SHOW:
|
||||
showsWatched++
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
@@ -116,9 +92,11 @@ func (s *Service) getProfile(userId uint) (Profile, error) {
|
||||
showRuntime = c.Runtime
|
||||
}
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
slog.Debug("profile stat calculated",
|
||||
"show", c.Title,
|
||||
"runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
} else if c.Type == entity.MOVIE {
|
||||
case entity.MOVIE:
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// All accepted query filters for any media search.
|
||||
type AllParsableQueryFilters struct {
|
||||
Year int
|
||||
FirstYear int
|
||||
Adult bool
|
||||
}
|
||||
|
||||
// Takes query in and parses out any inline filters into a struct.
|
||||
// Takes out anything in supported format: `a:b`.
|
||||
// Returns:
|
||||
// - query string with any parsed filters removed.
|
||||
// - the parsed filters;
|
||||
func parseQueryFilters(query string) (string, AllParsableQueryFilters) {
|
||||
const segSplitStr = " "
|
||||
|
||||
m := AllParsableQueryFilters{}
|
||||
segs /* hehe */ := strings.SplitSeq(query, segSplitStr)
|
||||
// Segments of the query that aren't filters are added back to this slice
|
||||
// and filters are not, so when we join this slice back into a string at the
|
||||
// end, we are left with only the query and not any filters.
|
||||
finalQuery := []string{}
|
||||
notParsing := func(s string) {
|
||||
finalQuery = append(finalQuery, s)
|
||||
}
|
||||
for seg := range segs {
|
||||
split := strings.Split(seg, ":")
|
||||
if len(split) != 2 || (split[0] == "" || split[1] == "") {
|
||||
// We only support filtername:value, so:
|
||||
// - more or less than len of 2 = wrong; and
|
||||
// - k or v being empty = wrong.
|
||||
notParsing(seg)
|
||||
continue
|
||||
}
|
||||
// Add filter to struct.
|
||||
fkey := strings.ToLower(split[0])
|
||||
switch fkey {
|
||||
case "year", "y":
|
||||
i, _ := strconv.Atoi(split[1])
|
||||
m.Year = i
|
||||
case "fyear", "fy":
|
||||
i, _ := strconv.Atoi(split[1])
|
||||
m.FirstYear = i
|
||||
case "adult":
|
||||
if split[1] != "" {
|
||||
m.Adult = true
|
||||
}
|
||||
default:
|
||||
// If no key matches a supported one, then don't parse this either.
|
||||
notParsing(seg)
|
||||
}
|
||||
}
|
||||
slog.Debug("ParseQueryFilters: Done job.",
|
||||
"query", query,
|
||||
"finalQuery", finalQuery,
|
||||
"parsed_filters", m)
|
||||
return strings.Join(finalQuery, segSplitStr), m
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
type Expectation struct {
|
||||
Query string
|
||||
Struct AllParsableQueryFilters
|
||||
}
|
||||
|
||||
func TestParseQueryFilters(t *testing.T) {
|
||||
testSet := map[string]Expectation{
|
||||
// No effect on query without any filters.
|
||||
"Joker": {
|
||||
Query: "Joker",
|
||||
Struct: AllParsableQueryFilters{},
|
||||
},
|
||||
// Parses a lone filter correctly.
|
||||
"Joker year:2024": {
|
||||
Query: "Joker",
|
||||
Struct: AllParsableQueryFilters{
|
||||
Year: 2024,
|
||||
},
|
||||
},
|
||||
// Multiple filters should parse successfully.
|
||||
"Harvest MOON year:2024 adult:true": {
|
||||
Query: "Harvest MOON",
|
||||
Struct: AllParsableQueryFilters{
|
||||
Year: 2024,
|
||||
Adult: true,
|
||||
},
|
||||
},
|
||||
// Filter before or after the title should still be parsed.
|
||||
"y:1999 Joker 2 adult:1": {
|
||||
Query: "Joker 2",
|
||||
Struct: AllParsableQueryFilters{
|
||||
Year: 1999,
|
||||
Adult: true,
|
||||
},
|
||||
},
|
||||
// Test that the `2:` doesnt get parsed, since it's common for media to
|
||||
// have names that use colons like that.
|
||||
"y:t Joker 2: The sun rises! adult:1": {
|
||||
Query: "Joker 2: The sun rises!",
|
||||
Struct: AllParsableQueryFilters{
|
||||
Year: 0,
|
||||
Adult: true,
|
||||
},
|
||||
},
|
||||
// Test that a non whitelisted key "man", doesn't get parsed and removed
|
||||
// from the query.
|
||||
"spider man:new": {
|
||||
Query: "spider man:new",
|
||||
Struct: AllParsableQueryFilters{},
|
||||
},
|
||||
}
|
||||
|
||||
for query, exp := range testSet {
|
||||
q, m := parseQueryFilters(query)
|
||||
if q != exp.Query {
|
||||
t.Errorf("query '%v' doesn't match expected query '%v'", q, exp.Query)
|
||||
}
|
||||
if m != exp.Struct {
|
||||
t.Errorf("%v doesn't match expected %v", m, exp.Struct)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,27 +7,16 @@ package search
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/domain"
|
||||
"github.com/sbondCo/Watcharr/media/igdb"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ContentProvider interface {
|
||||
SearchContent(query string, pageNum int) (tmdb.TMDBSearchMultiResponse, error)
|
||||
SearchMovies(query string, pageNum int) (tmdb.TMDBSearchMoviesResponse, error)
|
||||
SearchTv(query string, pageNum int) (tmdb.TMDBSearchShowsResponse, error)
|
||||
SearchPeople(query string, pageNum int) (tmdb.TMDBSearchPeopleResponse, error)
|
||||
SearchByExternalId(id string, source string) (tmdb.TMDBSearchMultiResponse, error)
|
||||
MovieDetails(id string, country string, rParams map[string]string) (tmdb.TMDBMovieDetails, error)
|
||||
TvDetails(id string, country string, rParams map[string]string) (tmdb.TMDBShowDetails, error)
|
||||
}
|
||||
|
||||
type ServiceWatchedProvider interface {
|
||||
GetWatchedPage(userId uint, pp util.PaginationParams, wr domain.WatchedGetPageRequest, extraProps *domain.WatchedGetPageExtraProps) (util.PaginationResponse[entity.Watched, util.None], error)
|
||||
}
|
||||
@@ -35,20 +24,20 @@ type ServiceWatchedProvider interface {
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
cfg *config.ServerConfig
|
||||
contentProvider ContentProvider
|
||||
tmdb *tmdb.TMDB
|
||||
watchedProvider ServiceWatchedProvider
|
||||
}
|
||||
|
||||
func NewService(
|
||||
db *gorm.DB,
|
||||
cfg *config.ServerConfig,
|
||||
contentProvider ContentProvider,
|
||||
tmdb *tmdb.TMDB,
|
||||
watchedProvider ServiceWatchedProvider,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db,
|
||||
cfg,
|
||||
contentProvider,
|
||||
tmdb,
|
||||
watchedProvider,
|
||||
}
|
||||
}
|
||||
@@ -82,25 +71,64 @@ func (s *Service) Search(
|
||||
}
|
||||
}
|
||||
|
||||
// Parse query filters.
|
||||
query, qfilters := parseQueryFilters(r.Query)
|
||||
|
||||
switch r.Type {
|
||||
case domain.SearchTypeMulti:
|
||||
if err := s.searchMulti(r.Query, pp.Page, &resp); err != nil {
|
||||
sreq := tmdb.SearchUniversalOptions{
|
||||
Query: query,
|
||||
Page: pp.Page,
|
||||
Adult: qfilters.Adult,
|
||||
}
|
||||
greq := igdb.SearchOptions{
|
||||
Query: query,
|
||||
}
|
||||
if err := s.searchMulti(sreq, greq, &resp); err != nil {
|
||||
return resp, errors.New("multi search failed")
|
||||
}
|
||||
case domain.SearchTypeMovie:
|
||||
if err := s.searchMovie(r.Query, pp.Page, &resp); err != nil {
|
||||
sreq := tmdb.SearchMoviesOptions{
|
||||
SearchUniversalOptions: tmdb.SearchUniversalOptions{
|
||||
Query: query,
|
||||
Page: pp.Page,
|
||||
Adult: qfilters.Adult,
|
||||
},
|
||||
Year: qfilters.Year,
|
||||
PrimaryYear: qfilters.FirstYear,
|
||||
}
|
||||
if err := s.searchMovie(sreq, &resp); err != nil {
|
||||
return resp, errors.New("movie search failed")
|
||||
}
|
||||
case domain.SearchTypeShow:
|
||||
if err := s.searchTv(r.Query, pp.Page, &resp); err != nil {
|
||||
return resp, errors.New("tv search failed")
|
||||
sreq := tmdb.SearchShowsOptions{
|
||||
SearchUniversalOptions: tmdb.SearchUniversalOptions{
|
||||
Query: query,
|
||||
Page: pp.Page,
|
||||
Adult: qfilters.Adult,
|
||||
},
|
||||
Year: qfilters.Year,
|
||||
PrimaryYear: qfilters.FirstYear,
|
||||
}
|
||||
if err := s.searchShow(sreq, &resp); err != nil {
|
||||
return resp, errors.New("show search failed")
|
||||
}
|
||||
case domain.SearchTypePerson:
|
||||
if err := s.searchPeople(r.Query, pp.Page, &resp); err != nil {
|
||||
sreq := tmdb.SearchUniversalOptions{
|
||||
Query: query,
|
||||
Page: pp.Page,
|
||||
Adult: qfilters.Adult,
|
||||
}
|
||||
if err := s.searchPeople(sreq, &resp); err != nil {
|
||||
return resp, errors.New("person search failed")
|
||||
}
|
||||
case domain.SearchTypeGame:
|
||||
if err := s.searchGame(r.Query, pp.Page, &resp); err != nil {
|
||||
greq := igdb.SearchOptions{
|
||||
Query: query,
|
||||
Year: qfilters.Year,
|
||||
PrimaryYear: qfilters.FirstYear,
|
||||
}
|
||||
if err := s.searchGame(greq, &resp); err != nil {
|
||||
return resp, errors.New("game search failed")
|
||||
}
|
||||
}
|
||||
@@ -112,13 +140,13 @@ func (s *Service) Search(
|
||||
// TODO either with a header OR a result added to array of type error
|
||||
// SearchMulti is TMDB Multi search but with game data added to first page.
|
||||
func (s *Service) searchMulti(
|
||||
query string,
|
||||
page int,
|
||||
req tmdb.SearchUniversalOptions,
|
||||
igdbReq igdb.SearchOptions,
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchMulti: Running.", "query", query, "page", page)
|
||||
slog.Debug("searchMulti: Running.", "req", req)
|
||||
// TMDB
|
||||
tmdbRes, err := s.contentProvider.SearchContent(query, page)
|
||||
tmdbRes, err := s.tmdb.SearchMulti(req)
|
||||
if err != nil {
|
||||
slog.Error("SearchMulti: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -130,8 +158,8 @@ func (s *Service) searchMulti(
|
||||
)
|
||||
}
|
||||
// IGDB (we will only get results for the first page)
|
||||
if page == 1 && s.cfg.TwitchEnabled() {
|
||||
igdbRes, err := s.cfg.TWITCH.Search(query)
|
||||
if req.Page == 1 && s.cfg.TwitchEnabled() {
|
||||
igdbRes, err := s.cfg.TWITCH.Search(igdbReq)
|
||||
if err != nil {
|
||||
slog.Error("SearchMulti: Failed to search igdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -150,12 +178,11 @@ func (s *Service) searchMulti(
|
||||
}
|
||||
|
||||
func (s *Service) searchMovie(
|
||||
query string,
|
||||
page int,
|
||||
req tmdb.SearchMoviesOptions,
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchMovie: Running.", "query", query, "page", page)
|
||||
tmdbRes, err := s.contentProvider.SearchMovies(query, page)
|
||||
slog.Debug("searchMovie: Running.", "req", req)
|
||||
tmdbRes, err := s.tmdb.SearchMovies(req)
|
||||
if err != nil {
|
||||
slog.Error("SearchMovie: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -177,7 +204,9 @@ func (s *Service) searchMovieById(
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchMovieById: Running.", "id", id)
|
||||
details, err := s.contentProvider.MovieDetails(id, "", map[string]string{})
|
||||
details, err := s.tmdb.MovieDetails(tmdb.MovieDetailsOptions{
|
||||
ID: id,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("searchMovieById: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -192,15 +221,14 @@ func (s *Service) searchMovieById(
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) searchTv(
|
||||
query string,
|
||||
page int,
|
||||
func (s *Service) searchShow(
|
||||
req tmdb.SearchShowsOptions,
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchTv: Running.", "query", query, "page", page)
|
||||
tmdbRes, err := s.contentProvider.SearchTv(query, page)
|
||||
slog.Debug("searchTv: Running.", "req", req)
|
||||
tmdbRes, err := s.tmdb.SearchShows(req)
|
||||
if err != nil {
|
||||
slog.Error("searchTv: Failed to search tmdb!", "error", err)
|
||||
slog.Error("searchShow: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
}
|
||||
for _, v := range tmdbRes.Results {
|
||||
@@ -220,7 +248,9 @@ func (s *Service) searchTvById(
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchTvById: Running.", "id", id)
|
||||
details, err := s.contentProvider.TvDetails(id, "", map[string]string{})
|
||||
details, err := s.tmdb.ShowDetails(tmdb.ShowDetailsOptions{
|
||||
ID: id,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("searchTvById: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -236,12 +266,11 @@ func (s *Service) searchTvById(
|
||||
}
|
||||
|
||||
func (s *Service) searchPeople(
|
||||
query string,
|
||||
page int,
|
||||
req tmdb.SearchUniversalOptions,
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchPeople: Running.", "query", query, "page", page)
|
||||
tmdbRes, err := s.contentProvider.SearchPeople(query, page)
|
||||
slog.Debug("searchPeople: Running.", "req", req)
|
||||
tmdbRes, err := s.tmdb.SearchPeople(req)
|
||||
if err != nil {
|
||||
slog.Error("searchPeople: Failed to search tmdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -259,12 +288,11 @@ func (s *Service) searchPeople(
|
||||
}
|
||||
|
||||
func (s *Service) searchGame(
|
||||
query string,
|
||||
page int,
|
||||
req igdb.SearchOptions,
|
||||
resp *domain.SearchResponse,
|
||||
) error {
|
||||
slog.Debug("searchGame: Running.", "query", query, "page", page)
|
||||
igdbRes, err := s.cfg.TWITCH.Search(query)
|
||||
slog.Debug("searchGame: Running.", "req", req)
|
||||
igdbRes, err := s.cfg.TWITCH.Search(req)
|
||||
if err != nil {
|
||||
slog.Error("searchGame: Failed to search igdb!", "error", err)
|
||||
return errors.New("content request failed")
|
||||
@@ -353,231 +381,3 @@ func (s *Service) searchMyList(
|
||||
resp.TotalResults = internalRes.TotalResults
|
||||
return nil
|
||||
}
|
||||
|
||||
// Perform "special" direct search if possible using search query.
|
||||
// Eg: Search term is in provider:id format or is a supported url.
|
||||
func (s *Service) searchExtProviderById(
|
||||
query string,
|
||||
resp *domain.SearchResponse,
|
||||
) bool {
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
provider, providerID := s.getExtProviderFromQuery(queryLower)
|
||||
|
||||
if provider == "" || providerID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
slog.Debug("searchExtProviderById: Processing.",
|
||||
"provider", provider,
|
||||
"provider_id", providerID)
|
||||
|
||||
switch provider {
|
||||
case "movie":
|
||||
if err := s.searchMovieById(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
case "tv":
|
||||
if err := s.searchTvById(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
case "igdb":
|
||||
if err := s.searchGameById(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
case "igdb-slug":
|
||||
if err := s.searchGameBySlug(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// By default, if provider name isn't caught in above cases, just send
|
||||
// it to tmdb external id search.
|
||||
tmdbRes, err := s.contentProvider.SearchByExternalId(
|
||||
providerID,
|
||||
provider,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("searchExtProviderById: Failed to search tmdb!", "error", err)
|
||||
return false
|
||||
}
|
||||
resLen := len(tmdbRes.Results)
|
||||
if resLen <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range tmdbRes.Results {
|
||||
resp.Results = append(
|
||||
resp.Results,
|
||||
v.AsMedia(),
|
||||
)
|
||||
}
|
||||
resp.Page = 1
|
||||
resp.TotalPages = 1
|
||||
resp.TotalResults = int64(resLen)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Takes in query and returns (Provider, ProviderID) if found.
|
||||
func (s *Service) getExtProviderFromQuery(queryLower string) (string, string) {
|
||||
var provider string
|
||||
|
||||
// Before checking for provider:providerid format, check if query is
|
||||
// a supported url.
|
||||
if p, i := s.getExtProviderFromURL(queryLower); p != "" && i != "" {
|
||||
slog.Debug("getExtProviderFromQuery: Returning from parsed url.")
|
||||
return p, i
|
||||
}
|
||||
|
||||
querySplit := strings.Split(queryLower, ":")
|
||||
|
||||
if len(querySplit) != 2 {
|
||||
slog.Debug("getExtProviderFromQuery: querySplit len != 2")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
switch querySplit[0] {
|
||||
case "movie", // TMDB ID target
|
||||
"tv", // TMDB ID target
|
||||
"igdb", // IGDB ID target
|
||||
// The rest below are sent as is to tmdbs find by (external) id api.
|
||||
"imdb",
|
||||
"tvdb",
|
||||
"youtube",
|
||||
"wikidata",
|
||||
"facebook",
|
||||
"instagram",
|
||||
"twitter",
|
||||
"tiktok":
|
||||
provider = querySplit[0]
|
||||
// Any aliases we want to support
|
||||
case "i":
|
||||
case "imd":
|
||||
provider = "imdb"
|
||||
case "wd":
|
||||
case "wdt":
|
||||
provider = "wikidata"
|
||||
case "yt":
|
||||
provider = "youtube"
|
||||
case "thetvdb":
|
||||
provider = "tvdb"
|
||||
case "game":
|
||||
provider = "igdb"
|
||||
case "series":
|
||||
provider = "tv"
|
||||
default:
|
||||
slog.Debug("getExtProviderFromQuery: No provider found.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return provider, querySplit[1]
|
||||
}
|
||||
|
||||
// Takes in what may be a url. If it is and is a supported url
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderFromURL(maybeaurl string) (string, string) {
|
||||
u, err := url.Parse(maybeaurl)
|
||||
if err != nil || u.Host == "" {
|
||||
slog.Debug("getExtProviderFromURL: Doesn't look like a url.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
hostLower := strings.ToLower(u.Host)
|
||||
slog.Debug("getExtProviderFromURL: Looks like a url.",
|
||||
"host", hostLower,
|
||||
"path", u.Path)
|
||||
|
||||
// Using HasSuffix so for ex: www.imdb.com AND imdb.com will match.
|
||||
if strings.HasSuffix(hostLower, "imdb.com") {
|
||||
return s.getExtProviderIDFromIMDBURL(u)
|
||||
} else if strings.HasSuffix(hostLower, "themoviedb.org") {
|
||||
return s.getExtProviderIDFromTMDBURL(u)
|
||||
} else if strings.HasSuffix(hostLower, "igdb.com") {
|
||||
return s.getExtProviderIDFromIGDBURL(u)
|
||||
}
|
||||
|
||||
return "", " "
|
||||
}
|
||||
|
||||
// Extract id from IMDB url.
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderIDFromIMDBURL(u *url.URL) (string, string) {
|
||||
segments := strings.Split(
|
||||
// Trim start/end '/' to avoid empty items at start/end
|
||||
// of final slice.
|
||||
strings.Trim(u.Path, "/"),
|
||||
"/",
|
||||
)
|
||||
segmentsLen := len(segments)
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: Parsing path.",
|
||||
"segments", segments,
|
||||
"segments_len", segmentsLen)
|
||||
|
||||
if segmentsLen < 2 ||
|
||||
segments[0] != "title" ||
|
||||
!strings.HasPrefix(segments[1], "tt") {
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: path provided not supported.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return "imdb", segments[1]
|
||||
}
|
||||
|
||||
// Extract id from TMDB url.
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderIDFromTMDBURL(u *url.URL) (string, string) {
|
||||
// Split path by '/'
|
||||
segments := strings.Split(
|
||||
// Trim start/end '/' to avoid empty items at start/end
|
||||
// of final slice.
|
||||
strings.Trim(u.Path, "/"),
|
||||
"/",
|
||||
)
|
||||
segmentsLen := len(segments)
|
||||
slog.Debug("getExtProviderIDFromTMDBURL: Parsing path.",
|
||||
"segments", segments,
|
||||
"segments_len", segmentsLen)
|
||||
|
||||
// Check if segments of the path are valid as a tv/movie page.
|
||||
if segmentsLen < 2 ||
|
||||
(segments[0] != "tv" && segments[0] != "movie") ||
|
||||
segments[1] == "" {
|
||||
slog.Debug("getExtProviderIDFromTMDBURL: path provided not supported.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Extract id from second segment.
|
||||
segs2 := strings.SplitN(segments[1], "-", 2)
|
||||
slog.Debug("getExtProviderIDFromTMDBURL: Parsing media path segment.",
|
||||
"segments", segs2)
|
||||
if len(segs2) != 2 {
|
||||
slog.Warn("getExtProviderIDFromTMDBURL: segs2 doesn't have len of 2.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return segments[0], segs2[0]
|
||||
}
|
||||
|
||||
// Extract slug from IGDB url.
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderIDFromIGDBURL(u *url.URL) (string, string) {
|
||||
segments := strings.Split(
|
||||
// Trim start/end '/' to avoid empty items at start/end
|
||||
// of final slice.
|
||||
strings.Trim(u.Path, "/"),
|
||||
"/",
|
||||
)
|
||||
segmentsLen := len(segments)
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: Parsing path.",
|
||||
"segments", segments,
|
||||
"segments_len", segmentsLen)
|
||||
|
||||
if segmentsLen < 2 ||
|
||||
segments[0] != "games" {
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: path provided not supported.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return "igdb-slug", segments[1]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/sbondCo/Watcharr/domain"
|
||||
)
|
||||
|
||||
// Perform "special" direct search if possible using search query.
|
||||
// Eg: Search term is in provider:id format or is a supported url.
|
||||
func (s *Service) searchExtProviderById(
|
||||
query string,
|
||||
resp *domain.SearchResponse,
|
||||
) bool {
|
||||
queryLower := strings.ToLower(query)
|
||||
|
||||
provider, providerID := s.getExtProviderFromQuery(queryLower)
|
||||
|
||||
if provider == "" || providerID == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
slog.Debug("searchExtProviderById: Processing.",
|
||||
"provider", provider,
|
||||
"provider_id", providerID)
|
||||
|
||||
switch provider {
|
||||
case "movie":
|
||||
if err := s.searchMovieById(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
case "tv":
|
||||
if err := s.searchTvById(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
case "igdb":
|
||||
if err := s.searchGameById(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
case "igdb-slug":
|
||||
if err := s.searchGameBySlug(providerID, resp); err == nil {
|
||||
return true
|
||||
}
|
||||
default:
|
||||
// By default, if provider name isn't caught in above cases, just send
|
||||
// it to tmdb external id search.
|
||||
tmdbRes, err := s.tmdb.SearchByExternalId(
|
||||
providerID,
|
||||
provider,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("searchExtProviderById: Failed to search tmdb!",
|
||||
"error", err)
|
||||
return false
|
||||
}
|
||||
resLen := len(tmdbRes.Results)
|
||||
if resLen <= 0 {
|
||||
return false
|
||||
}
|
||||
for _, v := range tmdbRes.Results {
|
||||
resp.Results = append(
|
||||
resp.Results,
|
||||
v.AsMedia(),
|
||||
)
|
||||
}
|
||||
resp.Page = 1
|
||||
resp.TotalPages = 1
|
||||
resp.TotalResults = int64(resLen)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Takes in query and returns (Provider, ProviderID) if found.
|
||||
func (s *Service) getExtProviderFromQuery(queryLower string) (string, string) {
|
||||
var provider string
|
||||
|
||||
// Before checking for provider:providerid format, check if query is
|
||||
// a supported url.
|
||||
if p, i := s.getExtProviderFromURL(queryLower); p != "" && i != "" {
|
||||
slog.Debug("getExtProviderFromQuery: Returning from parsed url.")
|
||||
return p, i
|
||||
}
|
||||
|
||||
querySplit := strings.Split(queryLower, ":")
|
||||
|
||||
if len(querySplit) != 2 {
|
||||
slog.Debug("getExtProviderFromQuery: querySplit len != 2")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
switch querySplit[0] {
|
||||
case "movie", // TMDB ID target
|
||||
"tv", // TMDB ID target
|
||||
"igdb", // IGDB ID target
|
||||
// The rest below are sent as is to tmdbs find by (external) id api.
|
||||
"imdb",
|
||||
"tvdb",
|
||||
"youtube",
|
||||
"wikidata",
|
||||
"facebook",
|
||||
"instagram",
|
||||
"twitter",
|
||||
"tiktok":
|
||||
provider = querySplit[0]
|
||||
// Any aliases we want to support
|
||||
case "i":
|
||||
case "imd":
|
||||
provider = "imdb"
|
||||
case "wd":
|
||||
case "wdt":
|
||||
provider = "wikidata"
|
||||
case "yt":
|
||||
provider = "youtube"
|
||||
case "thetvdb":
|
||||
provider = "tvdb"
|
||||
case "game":
|
||||
provider = "igdb"
|
||||
case "series":
|
||||
provider = "tv"
|
||||
default:
|
||||
slog.Debug("getExtProviderFromQuery: No provider found.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return provider, querySplit[1]
|
||||
}
|
||||
|
||||
// Takes in what may be a url. If it is and is a supported url
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderFromURL(maybeaurl string) (string, string) {
|
||||
u, err := url.Parse(maybeaurl)
|
||||
if err != nil || u.Host == "" {
|
||||
slog.Debug("getExtProviderFromURL: Doesn't look like a url.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
hostLower := strings.ToLower(u.Host)
|
||||
slog.Debug("getExtProviderFromURL: Looks like a url.",
|
||||
"host", hostLower,
|
||||
"path", u.Path)
|
||||
|
||||
// Using HasSuffix so for ex: www.imdb.com AND imdb.com will match.
|
||||
if strings.HasSuffix(hostLower, "imdb.com") {
|
||||
return s.getExtProviderIDFromIMDBURL(u)
|
||||
} else if strings.HasSuffix(hostLower, "themoviedb.org") {
|
||||
return s.getExtProviderIDFromTMDBURL(u)
|
||||
} else if strings.HasSuffix(hostLower, "igdb.com") {
|
||||
return s.getExtProviderIDFromIGDBURL(u)
|
||||
}
|
||||
|
||||
return "", " "
|
||||
}
|
||||
|
||||
// Extract id from IMDB url.
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderIDFromIMDBURL(u *url.URL) (string, string) {
|
||||
segments := strings.Split(
|
||||
// Trim start/end '/' to avoid empty items at start/end
|
||||
// of final slice.
|
||||
strings.Trim(u.Path, "/"),
|
||||
"/",
|
||||
)
|
||||
segmentsLen := len(segments)
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: Parsing path.",
|
||||
"segments", segments,
|
||||
"segments_len", segmentsLen)
|
||||
|
||||
if segmentsLen < 2 ||
|
||||
segments[0] != "title" ||
|
||||
!strings.HasPrefix(segments[1], "tt") {
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: path provided not supported.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return "imdb", segments[1]
|
||||
}
|
||||
|
||||
// Extract id from TMDB url.
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderIDFromTMDBURL(u *url.URL) (string, string) {
|
||||
// Split path by '/'
|
||||
segments := strings.Split(
|
||||
// Trim start/end '/' to avoid empty items at start/end
|
||||
// of final slice.
|
||||
strings.Trim(u.Path, "/"),
|
||||
"/",
|
||||
)
|
||||
segmentsLen := len(segments)
|
||||
slog.Debug("getExtProviderIDFromTMDBURL: Parsing path.",
|
||||
"segments", segments,
|
||||
"segments_len", segmentsLen)
|
||||
|
||||
// Check if segments of the path are valid as a tv/movie page.
|
||||
if segmentsLen < 2 ||
|
||||
(segments[0] != "tv" && segments[0] != "movie") ||
|
||||
segments[1] == "" {
|
||||
slog.Debug("getExtProviderIDFromTMDBURL: path provided not supported.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Extract id from second segment.
|
||||
segs2 := strings.SplitN(segments[1], "-", 2)
|
||||
slog.Debug("getExtProviderIDFromTMDBURL: Parsing media path segment.",
|
||||
"segments", segs2)
|
||||
if len(segs2) != 2 {
|
||||
slog.Warn("getExtProviderIDFromTMDBURL: segs2 doesn't have len of 2.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return segments[0], segs2[0]
|
||||
}
|
||||
|
||||
// Extract slug from IGDB url.
|
||||
// Returns (Provider, ProviderID).
|
||||
func (s *Service) getExtProviderIDFromIGDBURL(u *url.URL) (string, string) {
|
||||
segments := strings.Split(
|
||||
// Trim start/end '/' to avoid empty items at start/end
|
||||
// of final slice.
|
||||
strings.Trim(u.Path, "/"),
|
||||
"/",
|
||||
)
|
||||
segmentsLen := len(segments)
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: Parsing path.",
|
||||
"segments", segments,
|
||||
"segments_len", segmentsLen)
|
||||
|
||||
if segmentsLen < 2 ||
|
||||
segments[0] != "games" {
|
||||
slog.Debug("getExtProviderIDFromIMDBURL: path provided not supported.")
|
||||
return "", ""
|
||||
}
|
||||
|
||||
return "igdb-slug", segments[1]
|
||||
}
|
||||
@@ -136,7 +136,9 @@ func (r *Router) UpdateAvatar(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := r.service.UploadUserAvatar(c, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
c.JSON(
|
||||
http.StatusInternalServerError,
|
||||
router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/image"
|
||||
"gorm.io/gorm"
|
||||
@@ -71,7 +65,7 @@ func (s *Service) UserUpdate(userId uint, ur entity.UserSettings) (entity.UserSe
|
||||
}
|
||||
|
||||
func (s *Service) UserGetSettings(userId uint) (entity.UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId)
|
||||
slog.Debug("UserGetSettings: Request running.", "user_id", userId)
|
||||
user := new(entity.User)
|
||||
res := s.db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
@@ -133,58 +127,43 @@ func (s *Service) UserUpdateBio(userId uint, newBio string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) UploadUserAvatar(c *gin.Context, userId uint) (entity.Image, error) {
|
||||
func (s *Service) UploadUserAvatar(
|
||||
c *gin.Context,
|
||||
userId uint,
|
||||
) (entity.Image, error) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
slog.Error("failed to get file", "error", err)
|
||||
return entity.Image{}, errors.New("no file found")
|
||||
}
|
||||
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
slog.Debug("UploadUserAvatar: An avatar is being uploaded",
|
||||
"name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
if err := image.IsValidImageType(f); err != nil {
|
||||
return entity.Image{}, errors.New("invalid image type")
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
slog.Error("uploadUserAvatar: Copy failed!", "error", err)
|
||||
return entity.Image{}, errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
defer f.Close()
|
||||
|
||||
slog.Debug("image hash calculated", "hash", hs, "first_letter", hs[0:1])
|
||||
|
||||
// Upload the file to specific dst.
|
||||
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
|
||||
c.SaveUploadedFile(file, path.Join(config.DataPath, outp))
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
img, err := image.
|
||||
NewSaver(s.db, "up", image.ValidateOptions{}).
|
||||
DownloadAndInsert(f)
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar seeking back to start of image failed", "error", err)
|
||||
slog.Error("UploadUserAvatar: DownloadAndInsert failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("processing image failed")
|
||||
}
|
||||
|
||||
// No need to remove old image, the daily cleanup task will handle removing unused ones.
|
||||
var img entity.Image
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Insert avatar into db
|
||||
img, err = image.InsertImage(s.db, hs, outp, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if img.ID == 0 {
|
||||
return errors.New("image has no id")
|
||||
}
|
||||
// Update users avatar to newly inserted
|
||||
if err := tx.Where("id = ?", userId).Updates(&entity.User{AvatarID: img.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar failed!", "error", err)
|
||||
return entity.Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
// No need to remove old image, the daily cleanup task will handle removing
|
||||
// unused ones.
|
||||
|
||||
// Update users avatar to newly inserted
|
||||
res := s.db.
|
||||
Where("id = ?", userId).
|
||||
Updates(&entity.User{AvatarID: img.ID})
|
||||
if res.Error != nil {
|
||||
slog.Error("UploadUserAvatar: Updating the users avatar in db failed!",
|
||||
"error", err)
|
||||
return entity.Image{}, errors.New("updating user failed")
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -53,10 +53,6 @@ type WatchedSeasonProvider interface {
|
||||
AddWatchedSeason(userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
|
||||
}
|
||||
|
||||
type ContentProvider interface {
|
||||
SeasonDetails(tvId string, seasonNumber string) (tmdb.TMDBSeasonDetails, error)
|
||||
}
|
||||
|
||||
type UserProvider interface {
|
||||
UserGetSettings(userId uint) (entity.UserSettings, error)
|
||||
}
|
||||
@@ -65,7 +61,7 @@ type Service struct {
|
||||
db *gorm.DB
|
||||
wp WatchedProvider
|
||||
wsp WatchedSeasonProvider
|
||||
cp ContentProvider
|
||||
tmdb *tmdb.TMDB
|
||||
activityProvider domain.ActivityAddProvider
|
||||
userProvider UserProvider
|
||||
}
|
||||
@@ -74,7 +70,7 @@ func NewService(
|
||||
db *gorm.DB,
|
||||
wp WatchedProvider,
|
||||
wsp WatchedSeasonProvider,
|
||||
cp ContentProvider,
|
||||
tmdb *tmdb.TMDB,
|
||||
activityProvider domain.ActivityAddProvider,
|
||||
userProvider UserProvider,
|
||||
) *Service {
|
||||
@@ -82,7 +78,7 @@ func NewService(
|
||||
db,
|
||||
wp,
|
||||
wsp,
|
||||
cp,
|
||||
tmdb,
|
||||
activityProvider,
|
||||
userProvider,
|
||||
}
|
||||
@@ -143,24 +139,54 @@ func (s *Service) AddWatchedEpisodes(userId uint, ar WatchedEpisodeAddRequest) (
|
||||
// (changing value to same value doesn't count).
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_STATUS_CHANGED, Data: string(json)})
|
||||
json, _ := json.Marshal(map[string]any{
|
||||
"season": ar.SeasonNumber,
|
||||
"episode": ar.EpisodeNumber,
|
||||
"status": ar.Status})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.EPISODE_STATUS_CHANGED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_RATING_CHANGED, Data: string(json)})
|
||||
json, _ := json.Marshal(map[string]any{
|
||||
"season": ar.SeasonNumber,
|
||||
"episode": ar.EpisodeNumber,
|
||||
"rating": ar.Rating})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.EPISODE_RATING_CHANGED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
act := domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_ADDED, Data: string(json)}
|
||||
json, _ := json.Marshal(map[string]any{
|
||||
"season": ar.SeasonNumber,
|
||||
"episode": ar.EpisodeNumber,
|
||||
"status": ar.Status,
|
||||
"rating": ar.Rating})
|
||||
act := domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.EPISODE_ADDED,
|
||||
Data: string(json),
|
||||
}
|
||||
if ar.AddActivity != "" {
|
||||
act.Type = ar.AddActivity
|
||||
}
|
||||
if !ar.AddActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.AddActivityDate
|
||||
}
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, act)
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, act, false)
|
||||
}
|
||||
episodeAddResp := WatchedEpisodeAddResponse{
|
||||
WatchedEpisodes: w.WatchedEpisodes,
|
||||
@@ -168,7 +194,13 @@ func (s *Service) AddWatchedEpisodes(userId uint, ar WatchedEpisodeAddRequest) (
|
||||
}
|
||||
if ar.Status != "" {
|
||||
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
|
||||
episodeAddResp.EpisodeStatusChangedHookResponse = s.hookEpisodeStatusChanged(userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
|
||||
episodeAddResp.EpisodeStatusChangedHookResponse =
|
||||
s.hookEpisodeStatusChanged(
|
||||
userId,
|
||||
ar.WatchedID,
|
||||
ar.SeasonNumber,
|
||||
ar.EpisodeNumber,
|
||||
ar.Status)
|
||||
}
|
||||
return episodeAddResp, nil
|
||||
}
|
||||
@@ -177,7 +209,12 @@ func (s *Service) AddWatchedEpisodes(userId uint, ar WatchedEpisodeAddRequest) (
|
||||
func (s *Service) rmWatchedEpisode(userId uint, id uint) (entity.Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "id", id)
|
||||
var watchedEpisode entity.WatchedEpisode
|
||||
resp := s.db.Clauses(clause.Returning{}).Model(&entity.WatchedEpisode{}).Unscoped().Where("id = ? AND user_id = ?", id, userId).Delete(&watchedEpisode)
|
||||
resp := s.db.
|
||||
Clauses(clause.Returning{}).
|
||||
Model(&entity.WatchedEpisode{}).
|
||||
Unscoped().
|
||||
Where("id = ? AND user_id = ?", id, userId).
|
||||
Delete(&watchedEpisode)
|
||||
if resp.Error != nil {
|
||||
slog.Error("Failed when removing a watched episode", "error", resp.Error)
|
||||
return entity.Activity{}, errors.New("failed when removing watched episode")
|
||||
@@ -194,7 +231,15 @@ func (s *Service) rmWatchedEpisode(userId uint, id uint) (entity.Activity, error
|
||||
"status": watchedEpisode.Status,
|
||||
"rating": watchedEpisode.Rating,
|
||||
})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedEpisode.WatchedID, Type: entity.EPISODE_REMOVED, Data: string(json)})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: watchedEpisode.WatchedID,
|
||||
Type: entity.EPISODE_REMOVED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
return addedActivity, nil
|
||||
}
|
||||
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
@@ -223,7 +268,15 @@ func (s *Service) hookEpisodeStatusChanged(userId uint, watchedId uint, seasonNu
|
||||
hookResponse := EpisodeStatusChangedHookResponse{}
|
||||
|
||||
addHookActivity := func(aType entity.ActivityType, data string) {
|
||||
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: watchedId,
|
||||
Type: aType,
|
||||
Data: (data),
|
||||
},
|
||||
false,
|
||||
)
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
|
||||
}
|
||||
|
||||
@@ -306,7 +359,7 @@ func (s *Service) hookEpisodeStatusChanged(userId uint, watchedId uint, seasonNu
|
||||
// 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 := s.cp.SeasonDetails(tmdbIdStr, seasonNumStr)
|
||||
seasonDetails, err := s.tmdb.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")
|
||||
|
||||
@@ -9,25 +9,18 @@ import (
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/domain"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
t *tmdb.TMDB
|
||||
s *Service
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
br *router.BaseRouter,
|
||||
t *tmdb.TMDB,
|
||||
service *Service,
|
||||
) *Router {
|
||||
func NewRouter(br *router.BaseRouter, service *Service) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
t: t,
|
||||
s: service,
|
||||
}
|
||||
}
|
||||
@@ -120,14 +113,19 @@ func (r *Router) AddWatched(c *gin.Context) {
|
||||
var ar domain.WatchedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.s.AddWatched(userId, ar, domain.WatchedAddExtraProps{
|
||||
ActivityType: entity.ADDED_WATCHED,
|
||||
})
|
||||
newWatched, err := r.s.AddWatched(
|
||||
userId,
|
||||
ar,
|
||||
domain.WatchedAddExtraProps{
|
||||
ActivityType: entity.ADDED_WATCHED,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
dto := domain.NewWatchedDtoForContentPage(&newWatched)
|
||||
c.JSON(http.StatusOK, dto)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
|
||||
@@ -46,7 +46,11 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w entity.Watched
|
||||
if resp := s.db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedSeasons").Find(&w); resp.Error != nil {
|
||||
if resp := s.db.
|
||||
Where("id = ? AND user_id = ?", ar.WatchedID, userId).
|
||||
Preload("Content").
|
||||
Preload("WatchedSeasons").
|
||||
Find(&w); resp.Error != nil {
|
||||
slog.Error("Failed when adding a watched season", "error", "failed to get watched item from db")
|
||||
return WatchedSeasonAddResponse{}, errors.New("failed when retrieving watched item")
|
||||
}
|
||||
@@ -96,11 +100,27 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.SEASON_STATUS_CHANGED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_RATING_CHANGED, Data: string(json)})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.SEASON_RATING_CHANGED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -113,14 +133,14 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
}
|
||||
}
|
||||
json, _ := json.Marshal(actData)
|
||||
act := domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_ADDED, Data: string(json)}
|
||||
act := domain.ActivityAddProps{WatchedID: w.ID, Type: entity.SEASON_ADDED, Data: string(json)}
|
||||
if ar.AddActivity != "" {
|
||||
act.Type = ar.AddActivity
|
||||
}
|
||||
if !ar.AddActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.AddActivityDate
|
||||
}
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, act)
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, act, false)
|
||||
}
|
||||
return WatchedSeasonAddResponse{
|
||||
WatchedSeasons: w.WatchedSeasons,
|
||||
@@ -132,7 +152,12 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
func (s *Service) RmWatchedSeason(userId uint, seasonId uint) (entity.Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "season_id", seasonId)
|
||||
var watchedSeason entity.WatchedSeason
|
||||
resp := s.db.Clauses(clause.Returning{}).Model(&entity.WatchedSeason{}).Unscoped().Where("id = ? AND user_id = ?", seasonId, userId).Delete(&watchedSeason)
|
||||
resp := s.db.
|
||||
Clauses(clause.Returning{}).
|
||||
Model(&entity.WatchedSeason{}).
|
||||
Unscoped().
|
||||
Where("id = ? AND user_id = ?", seasonId, userId).
|
||||
Delete(&watchedSeason)
|
||||
if resp.Error != nil {
|
||||
slog.Error("Failed when removing a watched season", "error", resp.Error)
|
||||
return entity.Activity{}, errors.New("failed when removing watched season")
|
||||
@@ -148,7 +173,15 @@ func (s *Service) RmWatchedSeason(userId uint, seasonId uint) (entity.Activity,
|
||||
"status": watchedSeason.Status,
|
||||
"rating": watchedSeason.Rating,
|
||||
})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedSeason.WatchedID, Type: entity.SEASON_REMOVED, Data: string(json)})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: watchedSeason.WatchedID,
|
||||
Type: entity.SEASON_REMOVED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
return addedActivity, nil
|
||||
}
|
||||
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
|
||||
@@ -22,11 +22,16 @@ type GameProvider interface {
|
||||
GetOrCache(igdbID int) (entity.Game, error)
|
||||
}
|
||||
|
||||
type UserProvider interface {
|
||||
UserGetSettings(userId uint) (entity.UserSettings, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
cp ContentProvider
|
||||
gameProvider GameProvider
|
||||
activityProvider domain.ActivityAddProvider
|
||||
userProvider UserProvider
|
||||
}
|
||||
|
||||
func NewService(
|
||||
@@ -34,12 +39,14 @@ func NewService(
|
||||
cp ContentProvider,
|
||||
gameProvider GameProvider,
|
||||
activityProvider domain.ActivityAddProvider,
|
||||
userProvider UserProvider,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db,
|
||||
cp,
|
||||
gameProvider,
|
||||
activityProvider,
|
||||
userProvider,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,8 +81,16 @@ func (s *Service) GetWatchedPage(
|
||||
"user_id", userId,
|
||||
"pagination_params", pp,
|
||||
"wr", wr)
|
||||
watched := new([]entity.Watched)
|
||||
|
||||
pRes := &util.PaginationResponse[entity.Watched, util.None]{}
|
||||
|
||||
// Get user settings.
|
||||
userSettings, err := s.userProvider.UserGetSettings(userId)
|
||||
if err != nil {
|
||||
return *pRes, errors.New("failed to get user settings")
|
||||
}
|
||||
|
||||
watched := new([]entity.Watched)
|
||||
res := s.db.
|
||||
Model(&entity.Watched{}).
|
||||
Where(&entity.Watched{UserID: userId})
|
||||
@@ -100,17 +115,18 @@ func (s *Service) GetWatchedPage(
|
||||
Preload("Tags").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Refine our results first (filters, sort);
|
||||
Scopes(
|
||||
watchedRefine(wr),
|
||||
).
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr, &userSettings)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
// that takes filtered out items into account.
|
||||
Scopes(
|
||||
util.Paginate(pp, pRes),
|
||||
).
|
||||
Scopes(util.Paginate(pp, pRes)).
|
||||
// Last we can apply our sorting.
|
||||
// Note: We must sort *after* we count, because currently
|
||||
// our 'Last Finished' sort, causes the COUNT query to also
|
||||
// be given extra JOINS that we don't want (because it slows it down).
|
||||
Scopes(watchedRefineSort(wr, userId)).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("GetWatchedPage: Failed!", "error", res.Error)
|
||||
@@ -162,17 +178,16 @@ func (s *Service) getPublicWatched(
|
||||
Preload("Tags").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Refine our results first (filters, sort);
|
||||
Scopes(
|
||||
watchedRefine(wr),
|
||||
).
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr, nil)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
// that takes filtered out items into account.
|
||||
Scopes(
|
||||
util.Paginate(pp, pRes),
|
||||
).
|
||||
Scopes(util.Paginate(pp, pRes)).
|
||||
// Sort options.
|
||||
// Note: See note above in GetWatchedPage.
|
||||
Scopes(watchedRefineSort(wr, userId)).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getPublicWatched: Failed!", "error", res.Error)
|
||||
@@ -186,7 +201,10 @@ func (s *Service) getPublicWatched(
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
func (s *Service) GetWatchedItemById(userId uint, id uint) (entity.Watched, error) {
|
||||
watched := new(entity.Watched)
|
||||
res := s.db.Model(&entity.Watched{}).Preload("Content").Where("user_id = ? AND id = ?", userId, id).Find(&watched)
|
||||
res := s.db.Model(&entity.Watched{}).
|
||||
Preload("Content").
|
||||
Where("user_id = ? AND id = ?", userId, id).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("GetWatchedItemById: Failed!", "error", res.Error)
|
||||
return entity.Watched{}, res.Error
|
||||
@@ -440,7 +458,8 @@ func (s *Service) AddWatched(
|
||||
&watched,
|
||||
); err != nil {
|
||||
// Try to restore the entry if unique contraint hit.
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.")
|
||||
slog.Error("AddWatched: Failed to restore existing watched entry.",
|
||||
"error", err)
|
||||
// Returns watched too because handlers of certain errors
|
||||
// may need it (and it's ID since we could have fetched it here)
|
||||
return watched, err
|
||||
@@ -453,7 +472,7 @@ func (s *Service) AddWatched(
|
||||
slog.Debug("AddWatched: Added watched list item", "item", watched)
|
||||
|
||||
// Finally add activity
|
||||
activityAddReq := domain.ActivityAddRequest{
|
||||
activityAddReq := domain.ActivityAddProps{
|
||||
WatchedID: watched.ID,
|
||||
Type: extraProps.ActivityType,
|
||||
}
|
||||
@@ -466,9 +485,14 @@ func (s *Service) AddWatched(
|
||||
} else {
|
||||
activityAddReq.Data = string(activityJson)
|
||||
}
|
||||
countAsPlay := false
|
||||
if ar.Status == entity.FINISHED {
|
||||
countAsPlay = true
|
||||
}
|
||||
act, _ := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
activityAddReq,
|
||||
countAsPlay,
|
||||
)
|
||||
watched.Activity = append(watched.Activity, act)
|
||||
|
||||
@@ -575,6 +599,11 @@ func (s *Service) updateWatched(
|
||||
ar domain.WatchedUpdateRequest,
|
||||
) (domain.WatchedUpdateResponse, error) {
|
||||
slog.Debug("UpdateWatched", "request_data", ar)
|
||||
if err := ar.Valid(); err != nil {
|
||||
slog.Error("UpdateWatched: UpdateRequest struct is invalid.",
|
||||
"id", id, "error", err)
|
||||
return domain.WatchedUpdateResponse{}, err
|
||||
}
|
||||
upwat := entity.Watched{}
|
||||
res := s.db.Model(&entity.Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
|
||||
if res.Error != nil {
|
||||
@@ -603,16 +632,49 @@ func (s *Service) updateWatched(
|
||||
}
|
||||
addedActivity := entity.Activity{}
|
||||
if ar.Rating != 0 {
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.RATING_CHANGED, Data: strconv.Itoa(int(ar.Rating))})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: id,
|
||||
Type: entity.RATING_CHANGED,
|
||||
Data: strconv.Itoa(int(ar.Rating)),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
if ar.Status != "" {
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.STATUS_CHANGED, Data: string(ar.Status)})
|
||||
countAsPlay := false
|
||||
if ar.Status == entity.FINISHED &&
|
||||
util.Deref(ar.LetCountAsPlay, true) != false {
|
||||
countAsPlay = true
|
||||
}
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: id,
|
||||
Type: entity.STATUS_CHANGED,
|
||||
Data: string(ar.Status),
|
||||
},
|
||||
countAsPlay,
|
||||
)
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.THOUGHTS_CHANGED})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: id,
|
||||
Type: entity.THOUGHTS_CHANGED,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.THOUGHTS_REMOVED, Data: originalThoughts})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: id,
|
||||
Type: entity.THOUGHTS_REMOVED,
|
||||
Data: originalThoughts,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
return domain.WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
@@ -665,6 +727,13 @@ func (s *Service) removeWatched(
|
||||
if res.RowsAffected <= 0 {
|
||||
return domain.WatchedRemoveResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.REMOVED_WATCHED})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: id,
|
||||
Type: entity.REMOVED_WATCHED,
|
||||
},
|
||||
false,
|
||||
)
|
||||
return domain.WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
@@ -38,19 +38,55 @@ func refineFilterType(db *gorm.DB, ft []util.SupportedMedia) {
|
||||
}
|
||||
|
||||
// Applies 'Status' filter.
|
||||
func refineFilterStatus(db *gorm.DB, f []entity.WatchedStatus) {
|
||||
func refineFilterStatus(
|
||||
db *gorm.DB,
|
||||
f []entity.WatchedStatus,
|
||||
userSettings *entity.UserSettings,
|
||||
) {
|
||||
if len(f) <= 0 {
|
||||
return
|
||||
}
|
||||
// Process the input data.
|
||||
fIncludesFinished := false
|
||||
for i := range f {
|
||||
// Ensure string **case** is valid WatchedStatus by converting to uppercase.
|
||||
f[i] = entity.WatchedStatus(strings.ToUpper(string(f[i])))
|
||||
if f[i] == entity.FINISHED {
|
||||
fIncludesFinished = true
|
||||
slog.Debug("refineFilterStatus: f includes FINISHED")
|
||||
}
|
||||
}
|
||||
// Apply the query.
|
||||
if fIncludesFinished &&
|
||||
userSettings != nil && util.Deref(userSettings.IncludePreviouslyWatched, false) {
|
||||
slog.Debug("refineFilterStatus: Performing query that includes previously watched.")
|
||||
db.
|
||||
// If status IN `f` OR any activity counts as a play for this
|
||||
// watched item.
|
||||
// NOTE: GORM adds parenthesis around this WHERE so that the OR
|
||||
// doesn't confuse the whole WHERE on the main query, so we don't
|
||||
// need to do that.
|
||||
Where(`watcheds.status IN ? OR EXISTS (
|
||||
SELECT 1
|
||||
FROM activities
|
||||
WHERE activities.watched_id = watcheds.id
|
||||
AND activities.count_as_play = 1
|
||||
)`, f)
|
||||
} else {
|
||||
slog.Debug("refineFilterStatus: Performing standard query.")
|
||||
db.Where("watcheds.status IN ?", f)
|
||||
}
|
||||
db.Where("watcheds.status IN ?", f)
|
||||
}
|
||||
|
||||
// Applies sorts to list.
|
||||
func refineSort(db *gorm.DB, sort domain.WatchedSort, dir domain.SortDirection) {
|
||||
// Takes in userId of user who owns the list we are sorting, since some sorts
|
||||
// may require it for subqueries (eg LastFinished).
|
||||
func refineSort(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
sort domain.WatchedSort,
|
||||
dir domain.SortDirection,
|
||||
) {
|
||||
if sort == "" {
|
||||
return
|
||||
}
|
||||
@@ -71,19 +107,27 @@ func refineSort(db *gorm.DB, sort domain.WatchedSort, dir domain.SortDirection)
|
||||
db.Order(obc(clause.Column{Name: "watcheds.updated_at"}))
|
||||
case domain.WatchedSortLastFinished:
|
||||
db.
|
||||
// This join looks for the latest activity for each watched entry
|
||||
// that indiciates a 'FINISHED' status. The date of these is used
|
||||
// in the sort below.
|
||||
// This seems the best way to support this sort with how our current
|
||||
// activity data is structured.
|
||||
// This join looks for the latest activity that counts as a play
|
||||
// for each watched entry. The date of these is used in the sort
|
||||
// below.
|
||||
// Note: Technically the join subquery will process ALL activities
|
||||
// that the user has by their user_id, BUT this is okay since we
|
||||
// use this sorting over the users entire watched list, so we want
|
||||
// to process every activity to join to main list for the sort
|
||||
// anyways. I'm making this note because previously I had left out
|
||||
// the user_id WHERE, which results in all activities in the table
|
||||
// being processed, which is obviously NOT wanted (cuz its slower).
|
||||
Joins(`LEFT JOIN (
|
||||
SELECT
|
||||
watched_id AS a_watched_id,
|
||||
MAX(COALESCE(custom_date, created_at)) AS a_sort_by_date
|
||||
FROM activities
|
||||
WHERE data LIKE "%FINISHED%" AND deleted_at IS NULL
|
||||
WHERE
|
||||
count_as_play = 1
|
||||
AND deleted_at IS NULL
|
||||
AND user_id = ?
|
||||
GROUP BY watched_id
|
||||
) q ON q.a_watched_id = watcheds.id`).
|
||||
) q ON q.a_watched_id = watcheds.id`, userId).
|
||||
Order(obc(clause.Column{Name: "q.a_sort_by_date"}))
|
||||
case domain.WatchedSortRating:
|
||||
db.Order(obc(clause.Column{Name: "watcheds.rating"}))
|
||||
@@ -109,15 +153,26 @@ func refineSortPinned(db *gorm.DB) {
|
||||
}
|
||||
|
||||
// list data.
|
||||
// gorm scope for applying sort and filters to watched
|
||||
func watchedRefine(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
|
||||
// gorm scope for applying filters to watched
|
||||
func watchedRefineFilter(
|
||||
wr domain.WatchedGetPageRequest,
|
||||
userSettings *entity.UserSettings,
|
||||
) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Apply filters
|
||||
refineFilterType(db, wr.FilterType)
|
||||
refineFilterStatus(db, wr.FilterStatus)
|
||||
// Apply sort
|
||||
refineSortPinned(db)
|
||||
refineSort(db, wr.Sort, wr.SortDir)
|
||||
refineFilterStatus(db, wr.FilterStatus, userSettings)
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
// list data.
|
||||
// gorm scope for applying sort to watched
|
||||
func watchedRefineSort(wr domain.WatchedGetPageRequest, userId uint) func(db *gorm.DB) *gorm.DB {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
// Apply sort
|
||||
refineSortPinned(db)
|
||||
refineSort(db, userId, wr.Sort, wr.SortDir)
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,28 +25,28 @@ func TestGetLatestWatchedInTv(t *testing.T) {
|
||||
}
|
||||
watchedEps := []entity.WatchedEpisode{
|
||||
{
|
||||
GormModel: dbmodel.GormModel{ID: 60},
|
||||
EpisodeNumber: 1,
|
||||
SeasonNumber: 1,
|
||||
Status: entity.FINISHED,
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{ID: 60},
|
||||
EpisodeNumber: 1,
|
||||
SeasonNumber: 1,
|
||||
Status: entity.FINISHED,
|
||||
},
|
||||
{
|
||||
GormModel: dbmodel.GormModel{ID: 70},
|
||||
EpisodeNumber: 5,
|
||||
SeasonNumber: 2,
|
||||
Status: entity.FINISHED,
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{ID: 70},
|
||||
EpisodeNumber: 5,
|
||||
SeasonNumber: 2,
|
||||
Status: entity.FINISHED,
|
||||
},
|
||||
{
|
||||
GormModel: dbmodel.GormModel{ID: 72},
|
||||
EpisodeNumber: 6,
|
||||
SeasonNumber: 3,
|
||||
Status: entity.DROPPED,
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{ID: 72},
|
||||
EpisodeNumber: 6,
|
||||
SeasonNumber: 3,
|
||||
Status: entity.DROPPED,
|
||||
},
|
||||
{
|
||||
GormModel: dbmodel.GormModel{ID: 90},
|
||||
EpisodeNumber: 2,
|
||||
SeasonNumber: 3,
|
||||
Status: entity.FINISHED,
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{ID: 90},
|
||||
EpisodeNumber: 2,
|
||||
SeasonNumber: 3,
|
||||
Status: entity.FINISHED,
|
||||
},
|
||||
}
|
||||
resp := watchedutil.GetLatestWatchedInTv(watchedSeasons, watchedEps)
|
||||
|
||||