mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
Compare commits
91 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 |
@@ -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
|
||||
|
||||
+177
-4
@@ -2,9 +2,161 @@
|
||||
|
||||
These changes are awaiting release:
|
||||
|
||||
## Fixed
|
||||
|
||||
- ProvidersList: Duplicate key error.
|
||||
|
||||
# [4.2.0] - 2026-08-03T07:45:00Z
|
||||
|
||||
> [!NOTE]
|
||||
> v4.2.0 - That's pretty funny right? Hah. Let's all chuckle together :)
|
||||
|
||||
## New
|
||||
|
||||
- Show `Airs On` date for episodes that have yet to air (thanks [@KarpachMarko]!).
|
||||
- Show `Aired Date` for episodes.
|
||||
- Import: Add `rating` column.
|
||||
- Import(text list): Support parsing a rating (out of 10, supporting decimals) in square brackets.
|
||||
- Show `End Year` for TV Shows that have ended or been canceled.
|
||||
- Add full release date and last release date to their respective year elements as titles (browser tooltip).
|
||||
- Search: Support inline filters for Non-Multi searches (ex: You can now search `Spider Man year:2026` or `Spider Man fyear:2026` if you care strictly about the first release year).
|
||||
- You'll find a new `filters` button on the search page that opens a modal explaining usage.
|
||||
|
||||
## Changed
|
||||
|
||||
- HTTP Requests: Replace `axios` with `Fetch`.
|
||||
|
||||
## Fixed
|
||||
|
||||
- Login: Display error when request to get available auth providers fails.
|
||||
- Removed some legacy code that still thought there was a `store.watchedList`.
|
||||
- DropDown: Fix whole page scrolling when pressing letter to scroll to first relevant dropdown item (only the dropdown list should scroll now, which is less jarring).
|
||||
- Media pages: Only show PosterImage if we have a poster path.
|
||||
- Import:
|
||||
- Mark import item as failed correctly when error is thrown from doImport.
|
||||
- Make the table horizontally scrollable when necessary (instead of just cutting off the end).
|
||||
|
||||
## Docs
|
||||
|
||||
- Update text import docs to show support for parsing ratings.
|
||||
- Update `installation/for-development` guide (commands were outdated).
|
||||
|
||||
## Maintenance
|
||||
|
||||
I have been slacking in this department, so there has been a lot of house cleaning.
|
||||
|
||||
- Package.json: Use exact version for all packages.
|
||||
- .npmrc: Enable `ignore-scripts` (still on npm 11) and set `min-release-age=14`.
|
||||
- Workflows: Upgrade action versions & set Node version to `24`.
|
||||
- Dockerfile: Upgrade node steps to use version `24`.
|
||||
- Workflows: test-pr-server: Set go-version-file for setup-go action.
|
||||
- Upgrade devDependencies:
|
||||
- eslint: 8.57.0 -> 10.7.0
|
||||
- eslint-config-prettier: 10.1.2 -> 10.1.8
|
||||
- eslint-plugin-svelte: 2.45.1 -> 3.20.0
|
||||
- prettier: 3.4.2 -> 3.9.5
|
||||
- prettier-plugin-svelte: 3.4.0 -> 4.1.1
|
||||
- svelte-eslint-parser: 0.42.0 -> 1.8.0
|
||||
- typescript-eslint: 8.32.1 -> 8.63.0
|
||||
- @sveltejs/adapter-node: 5.2.12 -> 5.5.7
|
||||
- @sveltejs/kit: 2.21.0 -> 2.69.3
|
||||
- @vite-pwa/sveltekit: 0.6.6 -> 1.1.0
|
||||
- sass: 1.97.3 -> 1.101.0
|
||||
- svelte: 5.17.3 -> 5.56.4
|
||||
- svelte-check: 4.1.3 -> 4.7.2
|
||||
- svelte-preprocess: 6.0.3 -> 6.0.5
|
||||
- typescript: 5.8.3 -> 6.0.3 (going to give v7 time to mature before I try it)
|
||||
- vite: 6.3.5 -> 8.1.4
|
||||
- Added devDependencies: `@eslint/js` (new eslint has split this module out into a new package) and `globals` (as a result of new eslint architecture, we now have this as a direct dev dependency).
|
||||
- ESLint: Migrate to flat config: I ran `npx sv add eslint` and modified the eslint.config.js it generated to line up better with our old one and work a bit better (i think) with our normal ts files. Also set `ecmaVersion` to `latest`, previously was `2020`.
|
||||
- Moved `env.d.ts` to `src` directory (which is covered by svelte-kits include) so that we can drop the custom `include` in our `tsconfig.json` (which when out of sync with the generated tsconfig by svelte-kit can lead to headaches because it's not immediately obvious, so this change will eliminate that dev time footgun).
|
||||
- Fixed lots of new (and probably old which may not have been applying with the old bad config) {ts,svelte} eslint rules, improving code quality.
|
||||
- Finally made tmdb media package function like the igdb media package, so that our direct tmdb interfacing code is neatly tucked away and isn't infesting the Content package (probs some more improvements I could make in regards to db caching, but that is for the future).
|
||||
- Upgrade server dependencies:
|
||||
- go: 1.25 -> 1.26
|
||||
- github.com/gin-contrib/cache: v1.4.1 -> v1.4.4
|
||||
- github.com/gin-contrib/cors: v1.7.6 -> v1.7.7
|
||||
- github.com/gin-gonic/gin: v1.11.0 -> v1.12.0
|
||||
- github.com/go-co-op/gocron/v2: v2.16.2 -> v2.22.0
|
||||
- github.com/go-playground/validator/v10: v10.27.0 -> v10.30.3
|
||||
- github.com/golang-jwt/jwt/v5: v5.2.2 -> v5.3.1
|
||||
- golang.org/x/crypto: v0.40.0 -> v0.54.0
|
||||
- gorm.io/gorm: v1.31.1: -> v1.31.2
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: [GitHub CR](https://github.com/orgs/sbondCo/packages/container/watcharr/1092583539?tag=v4.2.0) or [Docker Hub](https://hub.docker.com/layers/sbondco/watcharr/v4.2.0/images/sha256-1063b31d3254fe74ca1960eb78c1992dc809b4f7122b4d28547337a9fb8afc51).
|
||||
|
||||
# [4.1.1] - 2026-07-26T02:00:00Z
|
||||
|
||||
## Fixed
|
||||
|
||||
- Plex Login: Request JSON from plex api so that it doesn't return XML.
|
||||
- GHSA-6x53-2w54-v5rj (thanks to [@tonghuaroot] for reporting and patching!)
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: [GitHub CR](https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1066968415?tag=v4.1.1) or [Docker Hub](https://hub.docker.com/layers/sbondco/watcharr/v4.1.1/images/sha256-e71e9006e22e8111230ae2fc0e8a66c2b6de75112c784a11ab1b45faffeb8e8a).
|
||||
|
||||
# [4.1.0] - 2026-07-19T14:18:00Z
|
||||
|
||||
## Changed
|
||||
|
||||
- Profile: Stats no longer care about the `Include Previously Watched` setting. All previously watched items will be counted in the stats now.
|
||||
- Activity: Added `index` to `WatchedID` column to speed up queries.
|
||||
- SeasonsListEpisode when `Hide Spoilers` is on (thanks [@goestav]!):
|
||||
- Show episode spoilers if its status is `FINISHED`;
|
||||
- Allow changing status without showing spoilers (useful for when setting an episode to PLANNED, etc).
|
||||
|
||||
## Fixed
|
||||
|
||||
- `Include Previously Watched` regression (fixes https://github.com/sbondCo/Watcharr/issues/1027).
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Remove `dependabot.yml`.
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: [GitHub CR](https://github.com/orgs/sbondCo/packages/container/watcharr/1045300036?tag=v4.1.0) or [Docker Hub](https://hub.docker.com/layers/sbondco/watcharr/v4.1.0/images/sha256-8148b4bdd81e7fc2b412a4dd0a0a2bafa478bd42ab6d9824f9be10d1763719f4).
|
||||
|
||||
# [4.0.1] - 2026-07-16T10:12:00Z
|
||||
|
||||
## Changed
|
||||
|
||||
- Move notifications to its own component.
|
||||
- Use an `svg` for the favicon and change its `fill` to `white` for dark themed browsers.
|
||||
|
||||
## Fixed
|
||||
|
||||
- SpinnerTiny: Use svg instead of the css border trick to create the spinner fixing it looking more like a horseshoe than a circle on different browser scales (also added animation on the svg circle).
|
||||
- SpinnerTiny: Fix color being wrong on dark theme.
|
||||
- Watched: Also log the `error` on the "failed to restore existing watched entry" log.
|
||||
|
||||
## Maintenance
|
||||
|
||||
- workflows/test-pr-server: Add test step
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1036344762?tag=v4.0.1 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.1/images/sha256-3546329532facf6f55c779bb229ce997258ff1a89c4e5f9d66a92cc65ae80396).
|
||||
|
||||
# [4.0.0] - 2026-07-11T02:34:00Z
|
||||
|
||||
> [!CAUTION]
|
||||
> It is always highly recommended that you perform a backup before updating (https://watcharr.app/docs/server_config/backup)!
|
||||
|
||||
> [!NOTE]
|
||||
> v4 is finally here! Don't worry about the major version bump, all migrations will be handled automatically when you start the upgraded version.
|
||||
>
|
||||
> Have a read of the Data Migrations section below to get an understanding of the migrations we are applying in v4. You'll also notice that on the first start there will be a delay while migrations are applied.
|
||||
|
||||
> [!NOTE]
|
||||
> This release contains security fixes. If your server is exposed to the public and you have signing up enabled, you should update sooner rather than later!
|
||||
|
||||
## Data migrations
|
||||
|
||||
This is the first time migrations are being taken place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
|
||||
This is the first time migrations are taking place when updating Watcharr, please be mindful of that and **ensure you have your existing database backed up** incase of any errors.
|
||||
|
||||
### Backfilling `plays` data from users Activity.
|
||||
|
||||
@@ -39,6 +191,7 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
- Moved db to WAL journal_mode.
|
||||
- WatchedUpdateRequest: Manually validate instead of using complex struct tags.
|
||||
- Now properly validating WatchedStatus.
|
||||
- ViewTrailerButton: Use youtube-nocookie.com (thanks [@GreatGatsby102])
|
||||
|
||||
## Fixed
|
||||
|
||||
@@ -49,6 +202,8 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
- import: myanimelist: Don't import start/finish dates when they are empty.
|
||||
- Star and Play icons color.
|
||||
- Activity: Fixed automation tooltip going out of bounds by moving it to top.
|
||||
- Make `image` package a lot more robust (thanks [@4qu4r1um]).
|
||||
- GHSA-5q73-v9hv-4cf3
|
||||
|
||||
## Removed
|
||||
|
||||
@@ -59,6 +214,14 @@ If you backup your database by copying the .db file (while your server is stoppe
|
||||
|
||||
- Backup: Also note that `watcharr.db-wal` should be backed up along with the .db file since v3.
|
||||
|
||||
## New Contributors
|
||||
|
||||
- [@GreatGatsby102] made their first contribution in https://github.com/sbondCo/Watcharr/pull/1040
|
||||
|
||||
## Etc
|
||||
|
||||
- **Package**: https://github.com/sbondCo/Watcharr/pkgs/container/watcharr/1020169511?tag=v4.0.0 or on [docker hub](https://hub.docker.com/layers/sbondco/watcharr/v4.0.0/images/sha256-cd39b4fc0578ca374b2c7d65b410e29dc2859918a322e35e6b01f8e55d250f10).
|
||||
|
||||
# [3.0.1] - 2026-03-09
|
||||
|
||||
Hi All, delivered straight to your inbox today; you have bug fixes, some even improving general quality of life!!
|
||||
@@ -189,7 +352,7 @@ Many thanks to everyone who has worked on this release!
|
||||
## New
|
||||
|
||||
- About modal (accessible via face menu) by [@Clusters] in https://github.com/sbondCo/Watcharr/pull/811
|
||||
- System app theme (automatically swaps between light/dark themes depending on system config) by @antoniosarro in https://github.com/sbondCo/Watcharr/pull/822
|
||||
- System app theme (automatically swaps between light/dark themes depending on system config) by [@antoniosarro] in https://github.com/sbondCo/Watcharr/pull/822
|
||||
- Search shortcut (`Ctrl+S`) by [@IRHM] in https://github.com/sbondCo/Watcharr/pull/886
|
||||
|
||||
## Fixed
|
||||
@@ -1631,7 +1794,12 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
|
||||
|
||||
<!-- Version Changelog References (newest first) -->
|
||||
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...HEAD
|
||||
[Unreleased]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...HEAD
|
||||
[4.2.0]: https://github.com/sbondCo/Watcharr/compare/v4.1.1...v4.2.0
|
||||
[4.1.1]: https://github.com/sbondCo/Watcharr/compare/v4.1.0...v4.1.1
|
||||
[4.1.0]: https://github.com/sbondCo/Watcharr/compare/v4.0.1...v4.1.0
|
||||
[4.0.1]: https://github.com/sbondCo/Watcharr/compare/v4.0.0...v4.0.1
|
||||
[4.0.0]: https://github.com/sbondCo/Watcharr/compare/v3.0.1...v4.0.0
|
||||
[3.0.1]: https://github.com/sbondCo/Watcharr/compare/v3.0.0...v3.0.1
|
||||
[3.0.0]: https://github.com/sbondCo/Watcharr/compare/v2.1.1...v3.0.0
|
||||
[2.1.1]: https://github.com/sbondCo/Watcharr/compare/v2.1.0...v2.1.1
|
||||
@@ -1729,4 +1897,9 @@ Welcome to Watcharr :popcorn:, hope it is enjoyed and improves anyone's experien
|
||||
[@jigglycrumb]: https://github.com/jigglycrumb
|
||||
[@IvanBeke]: https://github.com/IvanBeke
|
||||
[@ParksideParade]: https://github.com/ParksideParade
|
||||
[Dredsen]: https://github.com/Dredsen
|
||||
[@Dredsen]: https://github.com/Dredsen
|
||||
[@GreatGatsby102]: https://github.com/GreatGatsby102
|
||||
[@4qu4r1um]: https://github.com/4qu4r1um
|
||||
[@goestav]: https://github.com/goestav
|
||||
[@tonghuaroot]: https://github.com/tonghuaroot
|
||||
[@KarpachMarko]: https://github.com/KarpachMarko
|
||||
|
||||
+7
-1
@@ -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!
|
||||
|
||||
+3
-3
@@ -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
-2
@@ -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/>.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
Generated
+3390
-3255
File diff suppressed because it is too large
Load Diff
+23
-23
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"license": "GPL-3.0-only",
|
||||
"version": "3.0.2-dev1",
|
||||
"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
@@ -1 +1 @@
|
||||
3.0.2-dev1
|
||||
4.2.1
|
||||
Vendored
+6
@@ -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
|
||||
|
||||
+30
-12
@@ -16,6 +16,10 @@ import (
|
||||
// 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.
|
||||
@@ -27,13 +31,27 @@ func New() (*gorm.DB, error) {
|
||||
slog.Error("New: Opening database failed.")
|
||||
return nil, err
|
||||
}
|
||||
if err := configure(db); err != nil {
|
||||
slog.Error("New: Configuring connection failed!", "error", 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.
|
||||
slog.Info("New: AutoMigrating")
|
||||
err = db.AutoMigrate(
|
||||
slog.Info("Setup: AutoMigrating")
|
||||
err := db.AutoMigrate(
|
||||
&migrate.MigrationRecord{},
|
||||
&entity.User{},
|
||||
&entity.UserServices{},
|
||||
@@ -50,21 +68,21 @@ func New() (*gorm.DB, error) {
|
||||
&entity.Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("New: Auto migration failed.")
|
||||
return nil, err
|
||||
slog.Error("Setup: Auto migration failed.")
|
||||
return err
|
||||
}
|
||||
slog.Info("New: AutoMigrated")
|
||||
slog.Info("Setup: AutoMigrated")
|
||||
// Perform our manual migrations.
|
||||
if err := migrate.Now(db); err != nil {
|
||||
slog.Error("New: Manual migrations failed.", "error", err)
|
||||
return nil, err
|
||||
slog.Error("Setup: Manual migrations failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
// Optimize database.
|
||||
if err := optimize(db); err != nil {
|
||||
slog.Error("New: Optimizing database failed.", "error", err)
|
||||
return nil, err
|
||||
slog.Error("Setup: Optimizing database failed.", "error", err)
|
||||
return err
|
||||
}
|
||||
return db, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// Configure our SQLite database connection.
|
||||
|
||||
@@ -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,6 +54,9 @@ func Now(db *gorm.DB) error {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Migration has NOT been applied before.. applying.",
|
||||
"id", mig.ID)
|
||||
|
||||
// Timing the migration.
|
||||
timeBeforeMig := time.Now()
|
||||
|
||||
@@ -89,7 +92,7 @@ func Now(db *gorm.DB) error {
|
||||
}
|
||||
}
|
||||
|
||||
slog.Debug("Migration applied successfully.",
|
||||
slog.Info("Migration applied successfully.",
|
||||
"id", mig.ID,
|
||||
"duration", time.Since(timeBeforeMig))
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ var migrations = []Migration{
|
||||
Model(&entity.Activity{}).
|
||||
Where("type IN ?", []entity.ActivityType{
|
||||
entity.IMPORTED_ADDED_WATCHED,
|
||||
// TODO: Should these be here?:
|
||||
entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
}).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
-275
@@ -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)
|
||||
|
||||
+26
-47
@@ -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,
|
||||
}
|
||||
@@ -363,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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
@@ -101,7 +116,7 @@ func (s *Service) GetWatchedPage(
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr)).
|
||||
Scopes(watchedRefineFilter(wr, &userSettings)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
@@ -164,7 +179,7 @@ func (s *Service) getPublicWatched(
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
// Apply filters first.
|
||||
Scopes(watchedRefineFilter(wr)).
|
||||
Scopes(watchedRefineFilter(wr, nil)).
|
||||
// Then count results (after filter);
|
||||
Count(&pRes.TotalResults).
|
||||
// Now calculate pagination properties with a TotalResults
|
||||
@@ -443,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
|
||||
|
||||
@@ -38,15 +38,44 @@ 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.
|
||||
@@ -125,11 +154,14 @@ func refineSortPinned(db *gorm.DB) {
|
||||
|
||||
// list data.
|
||||
// gorm scope for applying filters to watched
|
||||
func watchedRefineFilter(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
|
||||
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)
|
||||
refineFilterStatus(db, wr.FilterStatus, userSettings)
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
+27
-25
@@ -1,36 +1,37 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.25
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
github.com/gin-contrib/cache v1.4.1
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.16.2
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||
github.com/gin-contrib/cache v1.4.4
|
||||
github.com/gin-contrib/cors v1.7.7
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.22.0
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
gorm.io/gorm v1.31.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gomodule/redigo v1.9.2 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
@@ -46,19 +47,20 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
golang.org/x/arch v0.23.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
@@ -4,15 +4,21 @@ github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf h1:TqhNAT4zKbT
|
||||
github.com/bradfitz/gomemcache v0.0.0-20250403215159-8d39553ac7cf/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c=
|
||||
github.com/buckket/go-blurhash v1.1.0 h1:X5M6r0LIvwdvKiUtiNcRL2YlmOfMzYobI3VCKCZc9Do=
|
||||
github.com/buckket/go-blurhash v1.1.0/go.mod h1:aT2iqo5W9vu9GpyoLErKfTHwgODsZp3bQfXjXJUxNb8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
@@ -27,14 +33,22 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/cache v1.3.1 h1:EWjkOaLocs5fGt9feQaI7rt1GZbDyatFXEUh2/s3ZI8=
|
||||
github.com/gin-contrib/cache v1.3.1/go.mod h1:6Tme0p3QEF/Ck/KUcq7h/OAqZvUDjHRH1DtQbNgfIX0=
|
||||
github.com/gin-contrib/cache v1.4.1 h1:HcLwLfw7p+FasNp5VAnFbbBj9SzB4bDtswvon7wYSg4=
|
||||
github.com/gin-contrib/cache v1.4.1/go.mod h1:tykDV+FgItJHYEO0eCasuRYsZKPPyb4BYhAjuTlG6RM=
|
||||
github.com/gin-contrib/cache v1.4.4 h1:4Sasrroa8CrbRYQ3aEMutRJGhz7ujyPlKvAPmJdIx9U=
|
||||
github.com/gin-contrib/cache v1.4.4/go.mod h1:OfwzOu0CcBcQYgvc+wg7moQWFzmJCKqmo0NU7Wx3xyQ=
|
||||
github.com/gin-contrib/cors v1.7.5 h1:cXC9SmofOrRg0w9PigwGlHG3ztswH6bqq4vJVXnvYMk=
|
||||
github.com/gin-contrib/cors v1.7.5/go.mod h1:4q3yi7xBEDDWKapjT2o1V7mScKDDr8k+jZ0fSquGoy0=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/cors v1.7.7 h1:Oh9joP463x7Mw72vhvJ61YQm8ODh9b04YR7vsOErD0Q=
|
||||
github.com/gin-contrib/cors v1.7.7/go.mod h1:K5tW0RkzJtWSiOdikXloy8VEZlgdVNpHNw8FpjUPNrE=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
@@ -43,8 +57,12 @@ github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2 h1:r08P663ikXiulLT9XaabkLypL/W9MoCIbqgQoAutyX4=
|
||||
github.com/go-co-op/gocron/v2 v2.16.2/go.mod h1:4YTLGCCAH75A5RlQ6q+h+VacO7CgjkgP0EJ+BEOXRSI=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
@@ -55,12 +73,20 @@ github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc
|
||||
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/gomodule/redigo v1.9.2 h1:HrutZBLhSIU8abiSfW8pj8mPhOyMYjZT/wcA4/L9L9s=
|
||||
github.com/gomodule/redigo v1.9.2/go.mod h1:KsU3hiK/Ay8U42qpaJk+kuNa3C+spxapWpM+ywhcgtw=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
@@ -112,8 +138,12 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/robfig/go-cache v0.0.0-20130306151617-9fc39e0dbf62 h1:pyecQtsPmlkCsMkYhT5iZ+sUXuwee+OvfuJjinEA3ko=
|
||||
@@ -138,41 +168,61 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
|
||||
golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
|
||||
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
|
||||
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -185,4 +235,6 @@ gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
+14
-108
@@ -2,33 +2,35 @@ package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// TODO now that this file is in the image package it no longer needs to have "image(s)"
|
||||
// in the name of all the functions..
|
||||
const (
|
||||
defMaxSize int64 = 10 * util.Mebibyte
|
||||
defMaxWidthHeight int64 = 7680
|
||||
defMaxPixels int64 = 10_000_000
|
||||
)
|
||||
|
||||
// Insert an image into database
|
||||
func InsertImage(db *gorm.DB, hash string, path string, f io.Reader) (entity.Image, error) {
|
||||
bh, _ := GetBlurHash(f)
|
||||
func Insert(
|
||||
db *gorm.DB,
|
||||
hash string,
|
||||
path string,
|
||||
b []byte,
|
||||
) (entity.Image, error) {
|
||||
br := bytes.NewReader(b)
|
||||
bh, _ := GetBlurHash(br)
|
||||
img := entity.Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
@@ -95,99 +97,3 @@ WHERE NOT EXISTS (
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func IsValidImageType(f multipart.File) error {
|
||||
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
|
||||
// Reading whole file is a waste.
|
||||
buff := make([]byte, 512)
|
||||
if _, err := f.Read(buff); err != nil {
|
||||
slog.Error("isValidImageType: failed to read file into buffer", "error", err)
|
||||
return errors.New("failed to verify if image is valid")
|
||||
}
|
||||
t := http.DetectContentType(buff)
|
||||
slog.Debug("isValidImageType", "type", t)
|
||||
if t != "image/png" && t != "image/jpeg" && t != "image/webp" && t != "image/gif" {
|
||||
slog.Debug("isValidImageType: rejecting file as not valid (supported) image type")
|
||||
return errors.New("invalid file type")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DownloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (entity.Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
// So we have the ability to seek.
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
slog.Error("DownloadAndInsertImage: Copy failed!", "error", err)
|
||||
return entity.Image{}, errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
|
||||
// Seek back for file
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(config.DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
} else {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
img, err := InsertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/internal/testutil"
|
||||
)
|
||||
|
||||
func TestDownloadAndInsertFromUrl(t *testing.T) {
|
||||
testutil.SetupLogging()
|
||||
db := testutil.SetupDB(t)
|
||||
|
||||
i, err := NewSaver(db, "test", ValidateOptions{}).
|
||||
DownloadAndInsertFromUrl(
|
||||
"https://github.com/sbondCo/Watcharr/raw/dev/screenshot/homepage.png")
|
||||
if err != nil {
|
||||
t.Fatalf("DownloadAndInsert call failed: %v", err)
|
||||
}
|
||||
|
||||
if i.ID == 0 || i.Path == "" || i.BlurHash == "" {
|
||||
t.Fatal("returned entity.Image doesn't have certain fields!",
|
||||
"id", i.ID, "path", i.Path, "blurhash", i.BlurHash)
|
||||
}
|
||||
|
||||
fullImgDataPath := path.Join(config.DataPath, i.Path)
|
||||
|
||||
// Verify file exists and looks right.
|
||||
if fi, err := os.Stat(fullImgDataPath); err != nil {
|
||||
t.Fatalf("os.stat failed %v", err)
|
||||
} else if fi.Size() <= 1 {
|
||||
t.Fatalf("image file size doesn't seem right: %v", fi.Size())
|
||||
} else if filepath.Ext(fi.Name()) != ".jpg" {
|
||||
t.Fatalf("image file name doesn't have .jpg ext: %s", fi.Name())
|
||||
}
|
||||
|
||||
if err := os.Remove(fullImgDataPath); err != nil {
|
||||
// Not a fatal error because this is extra logic only for testing,
|
||||
// but failing test because something in the real logic might possibly
|
||||
// have something to do with it failing and we should probably know.
|
||||
t.Errorf("removing the image file errored: %v", err)
|
||||
}
|
||||
|
||||
// Verify image is in db
|
||||
var c int64
|
||||
if res := db.
|
||||
Model(&entity.Image{}).
|
||||
Where(&entity.Image{ID: i.ID}).
|
||||
Count(&c); res.Error != nil {
|
||||
t.Fatalf("verification query failed: %v", res.Error)
|
||||
}
|
||||
if c != 1 {
|
||||
t.Fatalf("count of images in db doesn't look right: %v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/gif"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewSaver(db *gorm.DB, saveSubPath string, vo ValidateOptions) *Saver {
|
||||
return &Saver{
|
||||
db: db,
|
||||
SaveSubPath: saveSubPath,
|
||||
ValidateOptions: vo,
|
||||
}
|
||||
}
|
||||
|
||||
type Saver struct {
|
||||
// Database
|
||||
db *gorm.DB
|
||||
// Image save sub path.
|
||||
// Eg: `img/<subPath>/`
|
||||
SaveSubPath string
|
||||
// Validate options.
|
||||
ValidateOptions ValidateOptions
|
||||
}
|
||||
|
||||
// Download an image from `url` and insert it.
|
||||
func (s *Saver) DownloadAndInsertFromUrl(url string) (entity.Image, error) {
|
||||
slog.Debug("DownloadAndInsertFromUrl: Running.", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
return s.DownloadAndInsert(resp.Body)
|
||||
}
|
||||
|
||||
// Download an image from a provided Reader and insert it.
|
||||
func (s *Saver) DownloadAndInsert(r io.Reader) (entity.Image, error) {
|
||||
slog.Debug("DownloadAndInsert: Running.")
|
||||
|
||||
// Read all into memory.
|
||||
b, err := util.LimitedReadAll(r, defMaxSize)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Failed to read response!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Save the file.
|
||||
imgHash, imgPath, err := s.save(b)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Failed to save file!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Insert image into db.
|
||||
imge, err := Insert(s.db, imgHash, imgPath, b)
|
||||
if err != nil {
|
||||
slog.Error("DownloadAndInsert: Insert into db failed!", "error", err)
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return imge, nil
|
||||
}
|
||||
|
||||
// Creates the image file on disk.
|
||||
// Returns image hash, image path and error.
|
||||
func (s *Saver) save(b []byte) (string, string, error) {
|
||||
br := bytes.NewReader(b)
|
||||
// First we get a hash of the files contents.
|
||||
// Using the hash for filename has the benefit of us not storing duplicate
|
||||
// files just because their filename provided to us is different.
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, br); err != nil {
|
||||
slog.Error("save: Copy failed!", "error", err)
|
||||
return "", "", errors.New("copy failed")
|
||||
}
|
||||
hs := hex.EncodeToString(h.Sum(nil))
|
||||
slog.Debug("save: image hash calculated",
|
||||
"hash", hs,
|
||||
"first_letter", hs[0:1])
|
||||
|
||||
// Validate the file.
|
||||
// We always validate, even from "trusted sources!"
|
||||
b, ext, err := s.validate(b)
|
||||
if err != nil {
|
||||
slog.Error("save: Validate failed!", "error", err)
|
||||
return "", "", err
|
||||
}
|
||||
br = bytes.NewReader(b)
|
||||
|
||||
// Create paths for file.
|
||||
imgPath := path.Join(
|
||||
// Always outputs to `img/` dir.
|
||||
"img/",
|
||||
// Any sub path for separating images.
|
||||
s.SaveSubPath,
|
||||
// Sub-separate images by the starting character of their hash.
|
||||
hs[0:1],
|
||||
// File name is whole hash then the file extension.
|
||||
hs+ext)
|
||||
fullOutPath := path.Join(config.DataPath, imgPath)
|
||||
slog.Debug("save: Built path", "path", imgPath)
|
||||
|
||||
// Save file
|
||||
err = os.MkdirAll(path.Dir(fullOutPath), 0764)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
out, err := os.Create(fullOutPath)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return hs, imgPath, nil
|
||||
}
|
||||
|
||||
type ValidateAllowedFormat string
|
||||
|
||||
const (
|
||||
ValidateAllowedFormatJPEG ValidateAllowedFormat = "jpeg"
|
||||
ValidateAllowedFormatPNG ValidateAllowedFormat = "png"
|
||||
)
|
||||
|
||||
type ValidateOptions struct {
|
||||
ToFormat ValidateAllowedFormat
|
||||
}
|
||||
|
||||
// Validate the image safely.
|
||||
// Re-encodes the image in our own desired format, which helps verify the file
|
||||
// is a valid image and any undesirable data (eg think xss; appended html,
|
||||
// exit, etc) is not kept in the final image file we store.
|
||||
// We currently prefer jpg for the format we re-encode to, which also helps us
|
||||
// save storage space (adds compression, which the image we are validating
|
||||
// could lack or have a higher quality setting, etc).
|
||||
// Returns the new re-encoded image data, file extension, and error.
|
||||
// NOTE: Never use a user-set file extension (always validate we allow it).
|
||||
func (s *Saver) validate(b []byte) ([]byte, string, error) {
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
// Check image header for config/format.
|
||||
cfg, format, err := image.DecodeConfig(br)
|
||||
if err != nil {
|
||||
slog.Error("Validate: Failed to DecodeConfig", "error", err)
|
||||
return []byte{}, "", errors.New("invalid or bad image")
|
||||
}
|
||||
slog.Debug("Validate",
|
||||
"cfg.Width", cfg.Width,
|
||||
"cfg.Height", cfg.Height,
|
||||
"format", format)
|
||||
if int64(cfg.Width) > defMaxWidthHeight ||
|
||||
int64(cfg.Height) > defMaxWidthHeight {
|
||||
return []byte{}, "", errors.New("dimensions too large")
|
||||
}
|
||||
// Protect against images that max out the allowed width/height.
|
||||
// If we assume each pixel is 4 bytes, someone maxing out 8000x8000 would
|
||||
// mean ~235mb of data we need to decode into memory (i think!), but instead
|
||||
// of limiting the max width/height values too much, we can limit the max
|
||||
// amount of pixels to restrict the max size of the img pixels we'd allow.
|
||||
// Then weird aspect ratios are still allowed.
|
||||
// I'm definitely over-engineering this feature for a self-hosted movie list app lol.
|
||||
if int64(cfg.Width)*int64(cfg.Height) > defMaxPixels {
|
||||
return []byte{}, "", errors.New("i can't handle all those pixels")
|
||||
}
|
||||
|
||||
// Seek back, we are reading again below for decode.
|
||||
if _, err = br.Seek(0, 0); err != nil {
|
||||
slog.Error("Validate: Seeking reader to start failed", "error", err)
|
||||
return []byte{}, "", err
|
||||
}
|
||||
|
||||
// Decode image.
|
||||
// This should catch any malformed image files.
|
||||
var img image.Image
|
||||
switch format {
|
||||
case "png":
|
||||
img, err = png.Decode(br)
|
||||
case "jpeg":
|
||||
img, err = jpeg.Decode(br)
|
||||
case "gif":
|
||||
img, err = gif.Decode(br)
|
||||
default:
|
||||
return []byte{}, "", errors.New("unsupported image type")
|
||||
}
|
||||
if err != nil {
|
||||
slog.Error("full image decode failed", "error", err, "format", format)
|
||||
return []byte{}, "", errors.New("invalid or corrupt image")
|
||||
}
|
||||
|
||||
// Re-encode the image from our decoded data (any extra included, possibly
|
||||
// malicious data not part of the image should be gone now).
|
||||
// exif data, etc should also be gone now too which is good.
|
||||
// We just re-encode as jpeg right now, but if we wanted to, in the future
|
||||
// it's possible to encode different formats based on if we want to perserve
|
||||
// transparency from png, etc. OR maybe using webp will be easier and we
|
||||
// can just use that format since it supports transparency and animations.
|
||||
outfmt := ValidateAllowedFormatJPEG
|
||||
if s.ValidateOptions.ToFormat != "" {
|
||||
outfmt = s.ValidateOptions.ToFormat
|
||||
}
|
||||
|
||||
switch outfmt {
|
||||
case ValidateAllowedFormatJPEG:
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
|
||||
slog.Error("Validate: Failed to encode jpeg", "error", err)
|
||||
return []byte{}, "", errors.New("failed to encode image")
|
||||
}
|
||||
return buf.Bytes(), ".jpg", nil
|
||||
case ValidateAllowedFormatPNG:
|
||||
// NOTE: PNG->PNG re-encode can still result in output file being a bit
|
||||
// bigger, I think this is acceptable. I haven't seen any case where the
|
||||
// difference is big enough to care (sometimes can be smaller output too).
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
slog.Error("Validate: Failed to encode png", "error", err)
|
||||
return []byte{}, "", errors.New("failed to encode image")
|
||||
}
|
||||
return buf.Bytes(), ".png", nil
|
||||
default:
|
||||
return []byte{}, "", errors.New("invalid outfmt described")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
We don't really use the `internal` folder because none of our code is anything we'd ever expect anyone to import and rely on in their own codebase.
|
||||
|
||||
However, for some packages, it might make sense to put it here just as a signal to us (while developing) that this package is not code that goes into a prod build, etc (eg: `testutil`). This feels nicer, avoiding a scenario where our root folder has a bunch of real packages and ones that should never see prod mixed together (which is probably confusing).
|
||||
@@ -0,0 +1,55 @@
|
||||
// testutil is for testing code that we want to reuse for tests.
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// SetupLogging will configure the default slog logger.
|
||||
// Since our main app uses `slog`, this is useful for getting
|
||||
// debug logs to show, or hiding all, etc.
|
||||
// Controlled by env var `WTEST_LOG_LEVEL` (accepts: `debug` or `error`), if not
|
||||
// set, default Info log level is used.
|
||||
func SetupLogging() {
|
||||
level := slog.LevelInfo
|
||||
switch os.Getenv("WTEST_LOG_LEVEL") {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(
|
||||
os.Stdout, &slog.HandlerOptions{Level: level})))
|
||||
}
|
||||
|
||||
// Setup a fresh database for testing.
|
||||
// Exits test by using t.Fatalf if something fails.
|
||||
func SetupDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
// Open our test db.
|
||||
// Note: Could have used inmemory db, but it breaks our WAL migration and
|
||||
// errors out, and I don't wanna mess with prod code simply so I can use
|
||||
// an inmem db for testing, so we make a temporary file db.
|
||||
db, err := gorm.Open(
|
||||
sqlite.Open(filepath.Join(t.TempDir(), "test-watcharr.db")),
|
||||
&gorm.Config{TranslateError: true},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to open test db: %v", err)
|
||||
}
|
||||
|
||||
// Setup the db same as we do for prod.
|
||||
if err := database.Setup(db); err != nil {
|
||||
t.Fatalf("failed to migrate test db: %v", err)
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
@@ -183,19 +183,70 @@ func (i *IGDB) Init() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (i *IGDB) Search(q string) (GameSearchResponse, error) {
|
||||
slog.Debug("Search:", "query", q)
|
||||
// NOTE: If search options are added, ensure they are also added to the cache
|
||||
// key if necessary!
|
||||
type SearchOptions struct {
|
||||
Query string
|
||||
Year int
|
||||
PrimaryYear int
|
||||
}
|
||||
|
||||
// Turn supported filters from the SearchOptions struct into a `where` we can
|
||||
// send to APICalypse.
|
||||
func (o SearchOptions) AsWhere() string {
|
||||
f := []string{}
|
||||
if o.Year != 0 {
|
||||
y := strconv.Itoa(o.Year)
|
||||
// If `any` release of the game was on this year.
|
||||
f = append(f, "release_dates.y = "+y)
|
||||
}
|
||||
if o.PrimaryYear != 0 {
|
||||
start := strconv.FormatInt(
|
||||
time.
|
||||
Date(o.PrimaryYear, time.January, 1, 0, 0, 0, 0, time.UTC).
|
||||
Unix(),
|
||||
10)
|
||||
end := strconv.FormatInt(
|
||||
time.
|
||||
// We add one to the year and get the first day of the next year
|
||||
// BUT because day=0, we go back one day and end up with the
|
||||
// very last day of `PrimaryYear`.
|
||||
Date(o.PrimaryYear+1, time.January, 0, 23, 59, 59, 0, time.UTC).
|
||||
Unix(),
|
||||
10)
|
||||
// If `first` release of the game was on this year.
|
||||
f = append(f, "first_release_date > "+start)
|
||||
f = append(f, "first_release_date < "+end)
|
||||
}
|
||||
if len(f) <= 0 {
|
||||
return ""
|
||||
}
|
||||
return "where " + strings.Join(f, " & ") + ";"
|
||||
|
||||
}
|
||||
|
||||
func (i *IGDB) Search(o SearchOptions) (GameSearchResponse, error) {
|
||||
slog.Debug("Search:", "query", o.Query)
|
||||
var resp GameSearchResponse
|
||||
cacheKey := cache.CreateCacheKey("Search", q)
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"Search",
|
||||
o.Query,
|
||||
o.Year,
|
||||
o.PrimaryYear)
|
||||
if cache.GetCache(GameStore, cacheKey, &resp) {
|
||||
slog.Debug("Search: Returning cache.")
|
||||
return resp, nil
|
||||
}
|
||||
apiQuery := "fields " + fieldsForSearch + "; search \"" + o.Query + "\"; limit 40;"
|
||||
whereQ := o.AsWhere()
|
||||
if whereQ != "" {
|
||||
apiQuery += whereQ
|
||||
}
|
||||
err := i.req(
|
||||
igdbHost,
|
||||
"/games",
|
||||
map[string]string{},
|
||||
"fields "+fieldsForSearch+"; search \""+q+"\"; limit 40;",
|
||||
apiQuery,
|
||||
&resp,
|
||||
)
|
||||
if err != nil {
|
||||
|
||||
+10
-12
@@ -1,42 +1,40 @@
|
||||
package content
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
)
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
func transformProviders(c *any, country string) tmdb.WatchProviders {
|
||||
func transformProviders(c *any, country string) WatchProviders {
|
||||
slog.Debug("transformProviders called", "country", country)
|
||||
resp := tmdb.WatchProviders{}
|
||||
resp := WatchProviders{}
|
||||
|
||||
cmap, ok := (*c).(map[string]any)
|
||||
if !ok {
|
||||
slog.Error("transformProviders: Assertion failed")
|
||||
return tmdb.WatchProviders{}
|
||||
return WatchProviders{}
|
||||
}
|
||||
|
||||
rmap, ok := cmap["results"].(map[string]any)
|
||||
if !ok {
|
||||
slog.Warn("transformProviders: Couldn't find results property..")
|
||||
return tmdb.WatchProviders{}
|
||||
return WatchProviders{}
|
||||
}
|
||||
|
||||
val, ok := rmap[country]
|
||||
if !ok {
|
||||
slog.Warn("transformProviders: Couldn't find country..",
|
||||
"country", country)
|
||||
return tmdb.WatchProviders{}
|
||||
return WatchProviders{}
|
||||
}
|
||||
slog.Debug("transformProviders: Found country..", "obj", val)
|
||||
|
||||
rvmap, ok := val.(map[string]any)
|
||||
if !ok {
|
||||
slog.Warn("transformProviders: Couldn't assert country obj")
|
||||
return tmdb.WatchProviders{}
|
||||
return WatchProviders{}
|
||||
}
|
||||
|
||||
// Turning any into a type safe object we can use later.
|
||||
@@ -57,8 +55,8 @@ func transformProviders(c *any, country string) tmdb.WatchProviders {
|
||||
func transformProvidersType(
|
||||
ptype string,
|
||||
rvmap map[string]any,
|
||||
providers []tmdb.WatchProvider,
|
||||
) []tmdb.WatchProvider {
|
||||
providers []WatchProvider,
|
||||
) []WatchProvider {
|
||||
tm, ok := rvmap[ptype].([]any)
|
||||
if !ok {
|
||||
slog.Warn("transformProvidersType: Assertion failed")
|
||||
@@ -74,7 +72,7 @@ func transformProvidersType(
|
||||
continue
|
||||
}
|
||||
providers = append(providers,
|
||||
tmdb.WatchProvider{
|
||||
WatchProvider{
|
||||
ProviderName: providerName,
|
||||
})
|
||||
}
|
||||
+107
-127
@@ -5,28 +5,27 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/domain"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
|
||||
// Separated from `TMDBSearchResponse` so we can embed it for
|
||||
// easily assigning all page fields in one.
|
||||
type TMDBPageFields struct {
|
||||
type PageFields struct {
|
||||
Page int `json:"page"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
TotalResults int `json:"total_results"`
|
||||
}
|
||||
|
||||
type TMDBSearchResponse[R any] struct {
|
||||
TMDBPageFields
|
||||
type SearchResponse[R any] struct {
|
||||
PageFields
|
||||
Results []R `json:"results"`
|
||||
}
|
||||
|
||||
// A common "base" type for search results.
|
||||
// Some properties are used commonly for all types except Person, but
|
||||
// are still embedded in person for ease of use right now.
|
||||
type TMDBSearchResult struct {
|
||||
type SearchResult struct {
|
||||
// TMDB ID
|
||||
ID int `json:"id"`
|
||||
// Media Type (movie, tv, person)
|
||||
@@ -46,7 +45,7 @@ type TMDBSearchResult struct {
|
||||
|
||||
// Adds the base items to a Media struct, which can be used in the
|
||||
// structs that embed TMDBSearchResult to simplify and reduce duplication.
|
||||
func (t *TMDBSearchResult) AsMedia() domain.Media {
|
||||
func (t *SearchResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -71,12 +70,12 @@ func (t *TMDBSearchResult) AsMedia() domain.Media {
|
||||
// Multi Search
|
||||
//
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResult]
|
||||
type SearchMultiResponse struct {
|
||||
SearchResponse[SearchMultiResult]
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResult struct {
|
||||
TMDBSearchResult
|
||||
type SearchMultiResult struct {
|
||||
SearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Title string `json:"title,omitempty"`
|
||||
@@ -102,8 +101,8 @@ type TMDBSearchMultiResult struct {
|
||||
StillPath string `json:"still_path,omitempty"`
|
||||
}
|
||||
|
||||
func (t *TMDBSearchMultiResult) AsMedia() domain.Media {
|
||||
m := t.TMDBSearchResult.AsMedia()
|
||||
func (t *SearchMultiResult) AsMedia() domain.Media {
|
||||
m := t.SearchResult.AsMedia()
|
||||
|
||||
m.Name = t.Title
|
||||
if t.Name != "" {
|
||||
@@ -127,25 +126,16 @@ func (t *TMDBSearchMultiResult) AsMedia() domain.Media {
|
||||
return m
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchMultiResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResultWithWatched struct {
|
||||
TMDBSearchMultiResult
|
||||
Watched *entity.Watched `json:"watched,omitempty"`
|
||||
}
|
||||
|
||||
//
|
||||
// Movie Search
|
||||
//
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResult]
|
||||
type SearchMoviesResponse struct {
|
||||
SearchResponse[SearchMovieResult]
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResult struct {
|
||||
TMDBSearchResult
|
||||
type SearchMovieResult struct {
|
||||
SearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
@@ -157,8 +147,8 @@ type TMDBSearchMovieResult struct {
|
||||
Video bool `json:"video"`
|
||||
}
|
||||
|
||||
func (t *TMDBSearchMovieResult) AsMedia() domain.Media {
|
||||
m := t.TMDBSearchResult.AsMedia()
|
||||
func (t *SearchMovieResult) AsMedia() domain.Media {
|
||||
m := t.SearchResult.AsMedia()
|
||||
m.Name = t.Title
|
||||
if releaseDate, err := time.Parse("2006-01-02", t.ReleaseDate); err == nil {
|
||||
m.ReleaseDate = releaseDate
|
||||
@@ -168,25 +158,16 @@ func (t *TMDBSearchMovieResult) AsMedia() domain.Media {
|
||||
return m
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchMovieResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchMovieResultWithWatched struct {
|
||||
TMDBSearchMovieResult
|
||||
Watched *entity.Watched `json:"watched,omitempty"`
|
||||
}
|
||||
|
||||
//
|
||||
// Tv Shows Search
|
||||
//
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResult]
|
||||
type SearchShowsResponse struct {
|
||||
SearchResponse[SearchShowsResult]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResult struct {
|
||||
TMDBSearchResult
|
||||
type SearchShowsResult struct {
|
||||
SearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
@@ -198,8 +179,8 @@ type TMDBSearchShowsResult struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
func (t *TMDBSearchShowsResult) AsMedia() domain.Media {
|
||||
m := t.TMDBSearchResult.AsMedia()
|
||||
func (t *SearchShowsResult) AsMedia() domain.Media {
|
||||
m := t.SearchResult.AsMedia()
|
||||
m.Name = t.Name
|
||||
if releaseDate, err := time.Parse("2006-01-02", t.FirstAirDate); err == nil {
|
||||
m.ReleaseDate = releaseDate
|
||||
@@ -209,21 +190,12 @@ func (t *TMDBSearchShowsResult) AsMedia() domain.Media {
|
||||
return m
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponseWithWatched struct {
|
||||
TMDBSearchResponse[TMDBSearchShowsResultWithWatched]
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResultWithWatched struct {
|
||||
TMDBSearchShowsResult
|
||||
Watched *entity.Watched `json:"watched,omitempty"`
|
||||
}
|
||||
|
||||
//
|
||||
// People Search
|
||||
//
|
||||
|
||||
type TMDBSearchPeopleResult struct {
|
||||
TMDBSearchResult
|
||||
type SearchPeopleResult struct {
|
||||
SearchResult
|
||||
Adult bool `json:"adult"`
|
||||
Gender int `json:"gender"`
|
||||
KnownForDepartment string `json:"known_for_department"`
|
||||
@@ -250,31 +222,31 @@ type TMDBSearchPeopleResult struct {
|
||||
} `json:"known_for"`
|
||||
}
|
||||
|
||||
func (t *TMDBSearchPeopleResult) AsMedia() domain.Media {
|
||||
m := t.TMDBSearchResult.AsMedia()
|
||||
func (t *SearchPeopleResult) AsMedia() domain.Media {
|
||||
m := t.SearchResult.AsMedia()
|
||||
m.Name = t.Name
|
||||
m.ExtPosterPath = t.ProfilePath
|
||||
return m
|
||||
}
|
||||
|
||||
type TMDBSearchPeopleResponse struct {
|
||||
TMDBSearchResponse[TMDBSearchPeopleResult]
|
||||
type SearchPeopleResponse struct {
|
||||
SearchResponse[SearchPeopleResult]
|
||||
}
|
||||
|
||||
//
|
||||
// Search By External ID
|
||||
//
|
||||
|
||||
type TMDBFindByExternalIdResponse struct {
|
||||
// These are all a TMDBSearchMultiResult so our search func can easily
|
||||
// combine all of them into one []TMDBSearchMultiResult for response
|
||||
// to client (seems not easy to convert to TMDBSearchMultiResult for
|
||||
type FindByExternalIdResponse struct {
|
||||
// These are all a SearchMultiResult so our search func can easily
|
||||
// combine all of them into one []SearchMultiResult for response
|
||||
// to client (seems not easy to convert to SearchMultiResult for
|
||||
// concatenation after unmarshalling to correct type).
|
||||
MovieResults []TMDBSearchMultiResult `json:"movie_results"`
|
||||
PersonResults []TMDBSearchMultiResult `json:"person_results"`
|
||||
TvResults []TMDBSearchMultiResult `json:"tv_results"`
|
||||
TvSeasonResults []TMDBSearchMultiResult `json:"tv_season_results"`
|
||||
TvEpisodeResults []TMDBSearchMultiResult `json:"tv_episode_results"`
|
||||
MovieResults []SearchMultiResult `json:"movie_results"`
|
||||
PersonResults []SearchMultiResult `json:"person_results"`
|
||||
TvResults []SearchMultiResult `json:"tv_results"`
|
||||
TvSeasonResults []SearchMultiResult `json:"tv_season_results"`
|
||||
TvEpisodeResults []SearchMultiResult `json:"tv_episode_results"`
|
||||
}
|
||||
|
||||
//
|
||||
@@ -282,7 +254,7 @@ type TMDBFindByExternalIdResponse struct {
|
||||
// A base for details structs.
|
||||
//
|
||||
|
||||
type TMDBContentDetails struct {
|
||||
type ContentDetails struct {
|
||||
ID int `json:"id"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
@@ -315,7 +287,7 @@ type TMDBContentDetails struct {
|
||||
} `json:"spoken_languages"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
Videos TMDBContentVideos `json:"videos"`
|
||||
Videos ContentVideos `json:"videos"`
|
||||
// Raw watched providers object from tmdb
|
||||
WatchProviders interface{} `json:"watch/providers"`
|
||||
// Watched providers but after we apply our transformations to it.
|
||||
@@ -323,8 +295,8 @@ type TMDBContentDetails struct {
|
||||
}
|
||||
|
||||
// Adds the base items to a Media struct, which can be used in the
|
||||
// structs that embed TMDBSearchResult to simplify and reduce duplication.
|
||||
func (t *TMDBContentDetails) AsMedia() domain.Media {
|
||||
// structs that embed SearchResult to simplify and reduce duplication.
|
||||
func (t *ContentDetails) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -335,6 +307,7 @@ func (t *TMDBContentDetails) AsMedia() domain.Media {
|
||||
Rating: uint(t.VoteAverage * 10),
|
||||
RatingCount: uint(t.VoteCount),
|
||||
Homepage: t.Homepage,
|
||||
Status: t.Status,
|
||||
}
|
||||
// Genres
|
||||
for _, g := range t.Genres {
|
||||
@@ -384,8 +357,8 @@ func (t *TMDBContentDetails) AsMedia() domain.Media {
|
||||
// Movie Details
|
||||
//
|
||||
|
||||
type TMDBMovieDetails struct {
|
||||
TMDBContentDetails
|
||||
type MovieDetails struct {
|
||||
ContentDetails
|
||||
Adult bool `json:"adult"`
|
||||
BelongsToCollection any `json:"belongs_to_collection"`
|
||||
Budget uint32 `json:"budget"`
|
||||
@@ -398,12 +371,12 @@ type TMDBMovieDetails struct {
|
||||
Video bool `json:"video"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
ExternalIds TMDBExternalIdsMovie `json:"external_ids"`
|
||||
Similar TMDBMovieSimilar `json:"similar"`
|
||||
ExternalIds ExternalIdsMovie `json:"external_ids"`
|
||||
Similar MovieSimilar `json:"similar"`
|
||||
}
|
||||
|
||||
func (t *TMDBMovieDetails) AsMedia() domain.Media {
|
||||
m := t.TMDBContentDetails.AsMedia()
|
||||
func (t *MovieDetails) AsMedia() domain.Media {
|
||||
m := t.ContentDetails.AsMedia()
|
||||
m.Type = domain.MediaTypeTMDBMovie
|
||||
m.Name = t.Title
|
||||
m.Runtime = uint(t.Runtime)
|
||||
@@ -426,11 +399,11 @@ func (t *TMDBMovieDetails) AsMedia() domain.Media {
|
||||
// Movie Details Similar
|
||||
//
|
||||
|
||||
type TMDBMovieSimilar struct {
|
||||
TMDBSearchResponse[TMDBMovieSimilarResult]
|
||||
type MovieSimilar struct {
|
||||
SearchResponse[MovieSimilarResult]
|
||||
}
|
||||
|
||||
type TMDBMovieSimilarResult struct {
|
||||
type MovieSimilarResult struct {
|
||||
ID int `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Adult bool `json:"adult"`
|
||||
@@ -446,7 +419,7 @@ type TMDBMovieSimilarResult struct {
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
}
|
||||
|
||||
func (t *TMDBMovieSimilarResult) AsMedia() domain.Media {
|
||||
func (t *MovieSimilarResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -470,8 +443,8 @@ func (t *TMDBMovieSimilarResult) AsMedia() domain.Media {
|
||||
// Show Details
|
||||
//
|
||||
|
||||
type TMDBShowDetails struct {
|
||||
TMDBContentDetails
|
||||
type ShowDetails struct {
|
||||
ContentDetails
|
||||
CreatedBy []struct {
|
||||
ID int `json:"id"`
|
||||
CreditID string `json:"credit_id"`
|
||||
@@ -520,13 +493,13 @@ type TMDBShowDetails struct {
|
||||
Type string `json:"type"`
|
||||
|
||||
// Extra items because we use `append_to_response` on the request
|
||||
ExternalIds TMDBExternalIdsShow `json:"external_ids"`
|
||||
Keywords TMDBKeywords `json:"keywords"`
|
||||
Similar TMDBShowSimilar `json:"similar"`
|
||||
ExternalIds ExternalIdsShow `json:"external_ids"`
|
||||
Keywords Keywords `json:"keywords"`
|
||||
Similar ShowSimilar `json:"similar"`
|
||||
}
|
||||
|
||||
func (t *TMDBShowDetails) AsMedia() domain.Media {
|
||||
m := t.TMDBContentDetails.AsMedia()
|
||||
func (t *ShowDetails) AsMedia() domain.Media {
|
||||
m := t.ContentDetails.AsMedia()
|
||||
m.Type = domain.MediaTypeTMDBShow
|
||||
m.Name = t.Name
|
||||
if releaseDate, err := time.Parse("2006-01-02", t.FirstAirDate); err == nil {
|
||||
@@ -534,6 +507,13 @@ func (t *TMDBShowDetails) AsMedia() domain.Media {
|
||||
} else {
|
||||
slog.Error("AsMedia: Failed to parse release date", "name", m.Name, "error", err)
|
||||
}
|
||||
if releaseDateLast, err := time.Parse("2006-01-02", t.LastAirDate); err == nil {
|
||||
m.ReleaseDateLast = releaseDateLast
|
||||
} else {
|
||||
slog.Error("AsMedia: Failed to parse release date last",
|
||||
"name", m.Name,
|
||||
"error", err)
|
||||
}
|
||||
// IDS
|
||||
m.IDs.IMDB = t.ExternalIds.ImdbID
|
||||
m.IDs.Wikidata = t.ExternalIds.WikidataID
|
||||
@@ -567,7 +547,7 @@ func (t *TMDBShowDetails) AsMedia() domain.Media {
|
||||
return m
|
||||
}
|
||||
|
||||
type TMDBSeasonDetails struct {
|
||||
type SeasonDetails struct {
|
||||
ID string `json:"_id"`
|
||||
AirDate string `json:"air_date"`
|
||||
Episodes []struct {
|
||||
@@ -621,11 +601,11 @@ type TMDBSeasonDetails struct {
|
||||
// Show Details Similar
|
||||
//
|
||||
|
||||
type TMDBShowSimilar struct {
|
||||
TMDBSearchResponse[TMDBShowSimilarResult]
|
||||
type ShowSimilar struct {
|
||||
SearchResponse[ShowSimilarResult]
|
||||
}
|
||||
|
||||
type TMDBShowSimilarResult struct {
|
||||
type ShowSimilarResult struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Adult bool `json:"adult"`
|
||||
@@ -642,7 +622,7 @@ type TMDBShowSimilarResult struct {
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
}
|
||||
|
||||
func (t *TMDBShowSimilarResult) AsMedia() domain.Media {
|
||||
func (t *ShowSimilarResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -666,7 +646,7 @@ func (t *TMDBShowSimilarResult) AsMedia() domain.Media {
|
||||
// Person Details
|
||||
//
|
||||
|
||||
type TMDBPersonDetails struct {
|
||||
type PersonDetails struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Birthday string `json:"birthday"`
|
||||
@@ -681,7 +661,7 @@ type TMDBPersonDetails struct {
|
||||
Homepage string `json:"homepage"`
|
||||
}
|
||||
|
||||
func (t *TMDBPersonDetails) AsPersonDetailsResponse() domain.PersonDetailsResponse {
|
||||
func (t *PersonDetails) AsPersonDetailsResponse() domain.PersonDetailsResponse {
|
||||
m := domain.PersonDetailsResponse{
|
||||
Name: t.Name,
|
||||
PlaceOfBirth: t.PlaceOfBirth,
|
||||
@@ -710,13 +690,13 @@ func (t *TMDBPersonDetails) AsPersonDetailsResponse() domain.PersonDetailsRespon
|
||||
// Person Combined Credits
|
||||
//
|
||||
|
||||
type TMDBPersonCombinedCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []TMDBPersonCombinedCreditsCastResult `json:"cast"`
|
||||
// crew TMDBPersonCombinedCreditsCrew
|
||||
type PersonCombinedCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []PersonCombinedCreditsCastResult `json:"cast"`
|
||||
// crew PersonCombinedCreditsCrew
|
||||
}
|
||||
|
||||
type TMDBPersonCombinedCreditsCastResult struct {
|
||||
type PersonCombinedCreditsCastResult struct {
|
||||
ID int `json:"id"`
|
||||
OriginalLanguage string `json:"original_language"`
|
||||
EpisodeCount int `json:"episode_count"`
|
||||
@@ -741,7 +721,7 @@ type TMDBPersonCombinedCreditsCastResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
}
|
||||
|
||||
func (t *TMDBPersonCombinedCreditsCastResult) AsMedia() domain.Media {
|
||||
func (t *PersonCombinedCreditsCastResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -779,7 +759,7 @@ func (t *TMDBPersonCombinedCreditsCastResult) AsMedia() domain.Media {
|
||||
// Content Credits
|
||||
//
|
||||
|
||||
type TMDBContentCredits struct {
|
||||
type ContentCredits struct {
|
||||
ID int `json:"id"`
|
||||
Cast []struct {
|
||||
Adult bool `json:"adult"`
|
||||
@@ -823,12 +803,12 @@ const (
|
||||
TrendingTypePerson TrendingType = "person"
|
||||
)
|
||||
|
||||
type TMDBTrendingCombined struct {
|
||||
TMDBSearchResponse[TMDBTrendingCombinedResult]
|
||||
type TrendingCombined struct {
|
||||
SearchResponse[TrendingCombinedResult]
|
||||
}
|
||||
|
||||
type TMDBTrendingCombinedResult struct {
|
||||
TMDBSearchResult
|
||||
type TrendingCombinedResult struct {
|
||||
SearchResult
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
Title string `json:"title,omitempty"`
|
||||
@@ -845,8 +825,8 @@ type TMDBTrendingCombinedResult struct {
|
||||
ProfilePath string `json:"profile_path"`
|
||||
}
|
||||
|
||||
func (t *TMDBTrendingCombinedResult) AsMedia() domain.Media {
|
||||
m := t.TMDBSearchResult.AsMedia()
|
||||
func (t *TrendingCombinedResult) AsMedia() domain.Media {
|
||||
m := t.SearchResult.AsMedia()
|
||||
|
||||
m.Name = t.Title
|
||||
if t.Name != "" {
|
||||
@@ -891,11 +871,11 @@ type DiscoverOptions struct {
|
||||
// Discover Movies
|
||||
//
|
||||
|
||||
type TMDBDiscoverMovies struct {
|
||||
TMDBSearchResponse[TMDBDiscoverMoviesResult]
|
||||
type DiscoverMovies struct {
|
||||
SearchResponse[DiscoverMoviesResult]
|
||||
}
|
||||
|
||||
type TMDBDiscoverMoviesResult struct {
|
||||
type DiscoverMoviesResult struct {
|
||||
Adult bool `json:"adult"`
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
@@ -912,7 +892,7 @@ type TMDBDiscoverMoviesResult struct {
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
func (t *TMDBDiscoverMoviesResult) AsMedia() domain.Media {
|
||||
func (t *DiscoverMoviesResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -936,11 +916,11 @@ func (t *TMDBDiscoverMoviesResult) AsMedia() domain.Media {
|
||||
// Discover Shows
|
||||
//
|
||||
|
||||
type TMDBDiscoverShows struct {
|
||||
TMDBSearchResponse[TMDBDiscoverShowsResult]
|
||||
type DiscoverShows struct {
|
||||
SearchResponse[DiscoverShowsResult]
|
||||
}
|
||||
|
||||
type TMDBDiscoverShowsResult struct {
|
||||
type DiscoverShowsResult struct {
|
||||
BackdropPath string `json:"backdrop_path"`
|
||||
FirstAirDate string `json:"first_air_date"`
|
||||
GenreIds []int `json:"genre_ids"`
|
||||
@@ -956,7 +936,7 @@ type TMDBDiscoverShowsResult struct {
|
||||
VoteCount int `json:"vote_count"`
|
||||
}
|
||||
|
||||
func (t *TMDBDiscoverShowsResult) AsMedia() domain.Media {
|
||||
func (t *DiscoverShowsResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -980,17 +960,17 @@ func (t *TMDBDiscoverShowsResult) AsMedia() domain.Media {
|
||||
// Discover Shows
|
||||
//
|
||||
|
||||
type TMDBPopularPeople struct {
|
||||
TMDBSearchResponse[TMDBPopularPeopleResult]
|
||||
type PopularPeople struct {
|
||||
SearchResponse[PopularPeopleResult]
|
||||
}
|
||||
|
||||
type TMDBPopularPeopleResult struct {
|
||||
type PopularPeopleResult struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ProfilePath string `json:"profile_path"`
|
||||
}
|
||||
|
||||
func (t *TMDBPopularPeopleResult) AsMedia() domain.Media {
|
||||
func (t *PopularPeopleResult) AsMedia() domain.Media {
|
||||
m := domain.Media{
|
||||
IDs: domain.MediaIDs{
|
||||
TMDB: t.ID,
|
||||
@@ -1022,7 +1002,7 @@ type WatchProvider struct {
|
||||
DisplayPriority int `json:"display_priority"`
|
||||
}
|
||||
|
||||
type TMDBContentVideos struct {
|
||||
type ContentVideos struct {
|
||||
ID int `json:"id"`
|
||||
Results []struct {
|
||||
Iso6391 string `json:"iso_639_1"`
|
||||
@@ -1038,7 +1018,7 @@ type TMDBContentVideos struct {
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBExternalIds struct {
|
||||
type ExternalIds struct {
|
||||
ImdbID string `json:"imdb_id"`
|
||||
WikidataID string `json:"wikidata_id"`
|
||||
FacebookID string `json:"facebook_id"`
|
||||
@@ -1046,19 +1026,19 @@ type TMDBExternalIds struct {
|
||||
TwitterID string `json:"twitter_id"`
|
||||
}
|
||||
|
||||
type TMDBExternalIdsMovie struct {
|
||||
TMDBExternalIds
|
||||
type ExternalIdsMovie struct {
|
||||
ExternalIds
|
||||
}
|
||||
|
||||
type TMDBExternalIdsShow struct {
|
||||
TMDBExternalIds
|
||||
type ExternalIdsShow struct {
|
||||
ExternalIds
|
||||
FreebaseMid string `json:"freebase_mid"`
|
||||
FreebaseID string `json:"freebase_id"`
|
||||
TvdbID int `json:"tvdb_id"`
|
||||
TvrageID int `json:"tvrage_id"`
|
||||
}
|
||||
|
||||
type TMDBKeywords struct {
|
||||
type Keywords struct {
|
||||
// ID int `json:"id"`
|
||||
Results []struct {
|
||||
Name string `json:"name"`
|
||||
@@ -1066,7 +1046,7 @@ type TMDBKeywords struct {
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
type TMDBRegions struct {
|
||||
type Regions struct {
|
||||
Results []struct {
|
||||
ISO3166_1 string `json:"iso_3166_1"`
|
||||
English_Name string `json:"english_name"`
|
||||
|
||||
@@ -7,14 +7,22 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
gocache "github.com/robfig/go-cache"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
)
|
||||
|
||||
// TODO rewrite tmdb to work like how igdb package was made
|
||||
// TODO The *WithWatched structs likely need to go in the watched package (or with go 1.25 can we
|
||||
// fix needing so many extra structs for the *WithWatched types and functions)
|
||||
var ContentStore = gocache.New(time.Hour*24, time.Minute)
|
||||
|
||||
type ContentProvider interface {
|
||||
CacheContentShow(content ShowDetails, onlyUpdate bool) (entity.Content, error)
|
||||
CacheContentMovie(content MovieDetails, onlyUpdate bool) (entity.Content, error)
|
||||
}
|
||||
|
||||
type TMDB struct {
|
||||
Key string
|
||||
Key string
|
||||
contentProvider ContentProvider
|
||||
}
|
||||
|
||||
func NewTMDB(key string) *TMDB {
|
||||
@@ -23,6 +31,10 @@ func NewTMDB(key string) *TMDB {
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TMDB) AddContentProvider(contentProvider ContentProvider) {
|
||||
t.contentProvider = contentProvider
|
||||
}
|
||||
|
||||
func (t *TMDB) GetKey() string {
|
||||
if t.Key != "" {
|
||||
return t.Key //Config.TMDB_KEY
|
||||
@@ -30,7 +42,7 @@ func (t *TMDB) GetKey() string {
|
||||
return "d047fa61d926371f277e7a83c9c4ff2c"
|
||||
}
|
||||
|
||||
func (t *TMDB) APIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
func (t *TMDB) apiRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse("https://api.themoviedb.org/3")
|
||||
if err != nil {
|
||||
@@ -68,8 +80,8 @@ func (t *TMDB) APIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) Request(ep string, p map[string]string, resp interface{}) error {
|
||||
body, err := t.APIRequest(ep, p)
|
||||
func (t *TMDB) req(ep string, p map[string]string, resp interface{}) error {
|
||||
body, err := t.apiRequest(ep, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
)
|
||||
|
||||
func (t *TMDB) DiscoverMovies(
|
||||
o DiscoverOptions,
|
||||
pageNum int,
|
||||
region string,
|
||||
) (DiscoverMovies, error) {
|
||||
resp := new(DiscoverMovies)
|
||||
reqParams := map[string]string{
|
||||
"page": strconv.Itoa(pageNum),
|
||||
"region": region,
|
||||
}
|
||||
t.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 := t.req("/discover/movie", reqParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("DiscoverMovies: Request failed!", "error", err)
|
||||
return DiscoverMovies{}, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) DiscoverShows(
|
||||
o DiscoverOptions,
|
||||
pageNum int,
|
||||
region string,
|
||||
) (DiscoverShows, error) {
|
||||
resp := new(DiscoverShows)
|
||||
reqParams := map[string]string{
|
||||
"page": strconv.Itoa(pageNum),
|
||||
"region": region,
|
||||
}
|
||||
t.applyDiscoverOptionsToMap(false, o, reqParams)
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"DiscoverShows",
|
||||
pageNum,
|
||||
reqParams)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("DiscoverShows: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req("/discover/tv", reqParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("DiscoverShows: Request failed!", "error", err)
|
||||
return DiscoverShows{}, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) 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 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
)
|
||||
|
||||
type MovieDetailsOptions struct {
|
||||
// TMDB ID
|
||||
ID string
|
||||
// Country (currently used for watch providers)
|
||||
Country string
|
||||
// Request params map.
|
||||
Params map[string]string
|
||||
|
||||
// If CacheContentMovie should be ran or not.
|
||||
// If the caller wants to do its own caching to the db, it can use this
|
||||
// to avoid multiple calls to CacheContentMovie.
|
||||
DontRunDBCache bool
|
||||
}
|
||||
|
||||
func (t *TMDB) MovieDetails(o MovieDetailsOptions) (MovieDetails, error) {
|
||||
resp := new(MovieDetails)
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"MovieDetails",
|
||||
o.ID,
|
||||
o.Country,
|
||||
o.Params)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("MovieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req("/movie/"+o.ID, o.Params, &resp)
|
||||
if err != nil {
|
||||
slog.Error("MovieDetails: Request failed!", "error", err)
|
||||
return MovieDetails{}, errors.New("request failed")
|
||||
}
|
||||
resp.WatchProvidersTransformed = transformProviders(
|
||||
&resp.WatchProviders,
|
||||
o.Country)
|
||||
// We don't want this to linger around (in cache) since we have the
|
||||
// transformed version now..
|
||||
resp.WatchProviders = nil
|
||||
if !o.DontRunDBCache {
|
||||
go t.contentProvider.CacheContentMovie(*resp, true)
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) MovieCredits(id string) (ContentCredits, error) {
|
||||
resp := new(ContentCredits)
|
||||
err := t.req("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("MovieCredits: Request failed!", "error", err)
|
||||
return ContentCredits{}, errors.New("request failed")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
)
|
||||
|
||||
func (t *TMDB) PersonDetails(id string) (PersonDetails, error) {
|
||||
resp := new(PersonDetails)
|
||||
err := t.req("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("PersonDetails: Request failed!", "error", err)
|
||||
return PersonDetails{}, errors.New("request failed")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) PersonCredits(id string) (PersonCombinedCredits, error) {
|
||||
cacheKey := cache.CreateCacheKey("PersonCredits", id)
|
||||
resp := new(PersonCombinedCredits)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("PersonCredits: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/person/"+id+"/combined_credits",
|
||||
map[string]string{},
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("PersonCredits: Request failed!", "error", err)
|
||||
return PersonCombinedCredits{}, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) PopularPeople(pageNum int) (PopularPeople, error) {
|
||||
cacheKey := cache.CreateCacheKey("PopularPeople", pageNum)
|
||||
resp := new(PopularPeople)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("PopularPeople: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/person/popular",
|
||||
map[string]string{"page": strconv.Itoa(pageNum)},
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("PopularPeople: Request failed!", "error", err)
|
||||
return PopularPeople{}, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
)
|
||||
|
||||
// This file has all tmdb search methods.
|
||||
// Each helper has their own Options struct.
|
||||
// Each Options struct has an `AsParamsMap` function attached that converts
|
||||
// the properties of the struct into a query params map we can pass to the
|
||||
// requests (in a tmdb supported fashion).
|
||||
|
||||
// **NOTE:** Ensure any new options are also added to the cache keys of the
|
||||
// search functions using them!
|
||||
|
||||
// Options that all the Search structs support.
|
||||
type SearchUniversalOptions struct {
|
||||
Query string
|
||||
Page int
|
||||
Adult bool
|
||||
}
|
||||
|
||||
// Check if SearchUniversalOptions is valid.
|
||||
// Fixes `Page` to equal `1` if it is `0` (unset).
|
||||
func (o *SearchUniversalOptions) Valid() bool {
|
||||
if o.Query == "" {
|
||||
// A query is necessary!
|
||||
return false
|
||||
}
|
||||
if o.Page == 0 {
|
||||
o.Page = 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (o *SearchUniversalOptions) AsParamsMap() map[string]string {
|
||||
m := map[string]string{
|
||||
"query": o.Query,
|
||||
"page": strconv.Itoa(o.Page),
|
||||
}
|
||||
if o.Adult {
|
||||
m["include_adult"] = "true"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (t *TMDB) SearchMulti(
|
||||
o SearchUniversalOptions,
|
||||
) (SearchMultiResponse, error) {
|
||||
resp := new(SearchMultiResponse)
|
||||
if !o.Valid() {
|
||||
return *resp, errors.New("request is invalid")
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"SearchMulti",
|
||||
o.Query,
|
||||
o.Page,
|
||||
o.Adult)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchMulti: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/search/multi",
|
||||
o.AsParamsMap(),
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("SearchMulti: Request failed!", "error", err)
|
||||
return SearchMultiResponse{}, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
type SearchMoviesOptions struct {
|
||||
SearchUniversalOptions
|
||||
Year int
|
||||
PrimaryYear int
|
||||
}
|
||||
|
||||
func (o *SearchMoviesOptions) AsParamsMap() map[string]string {
|
||||
m := o.SearchUniversalOptions.AsParamsMap()
|
||||
if o.Year != 0 {
|
||||
m["year"] = strconv.Itoa(o.Year)
|
||||
}
|
||||
if o.PrimaryYear != 0 {
|
||||
m["primary_release_year"] = strconv.Itoa(o.PrimaryYear)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (t *TMDB) SearchMovies(
|
||||
o SearchMoviesOptions,
|
||||
) (SearchMoviesResponse, error) {
|
||||
resp := new(SearchMoviesResponse)
|
||||
if !o.Valid() {
|
||||
return *resp, errors.New("request is invalid")
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"SearchMovies",
|
||||
o.Query,
|
||||
o.Page,
|
||||
o.Adult,
|
||||
o.Year,
|
||||
o.PrimaryYear)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/search/movie",
|
||||
o.AsParamsMap(),
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("SearchMovies: Request failed!", "error", err)
|
||||
return SearchMoviesResponse{}, errors.New("request failed")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
type SearchShowsOptions struct {
|
||||
SearchUniversalOptions
|
||||
Year int
|
||||
PrimaryYear int
|
||||
}
|
||||
|
||||
func (o *SearchShowsOptions) AsParamsMap() map[string]string {
|
||||
m := o.SearchUniversalOptions.AsParamsMap()
|
||||
if o.Year != 0 {
|
||||
m["year"] = strconv.Itoa(o.Year)
|
||||
}
|
||||
if o.PrimaryYear != 0 {
|
||||
m["first_air_date_year"] = strconv.Itoa(o.PrimaryYear)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (t *TMDB) SearchShows(
|
||||
o SearchShowsOptions,
|
||||
) (SearchShowsResponse, error) {
|
||||
resp := new(SearchShowsResponse)
|
||||
if !o.Valid() {
|
||||
return *resp, errors.New("request is invalid")
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"SearchShows",
|
||||
o.Query,
|
||||
o.Page,
|
||||
o.Adult,
|
||||
o.Year,
|
||||
o.PrimaryYear)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchShows: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/search/tv",
|
||||
o.AsParamsMap(),
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("SearchShows: Request failed!", "error", err)
|
||||
return SearchShowsResponse{}, errors.New("request failed")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) SearchPeople(
|
||||
o SearchUniversalOptions,
|
||||
) (SearchPeopleResponse, error) {
|
||||
resp := new(SearchPeopleResponse)
|
||||
if !o.Valid() {
|
||||
return *resp, errors.New("request is invalid")
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"SearchPeople",
|
||||
o.Query,
|
||||
o.Page,
|
||||
o.Adult)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchPeople: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/search/person",
|
||||
o.AsParamsMap(),
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("SearchPeople: Request failed!", "error", err)
|
||||
return SearchPeopleResponse{}, errors.New("request failed")
|
||||
}
|
||||
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 (t *TMDB) SearchByExternalId(
|
||||
id string,
|
||||
source string,
|
||||
) (SearchMultiResponse, error) {
|
||||
resp := new(FindByExternalIdResponse)
|
||||
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 := t.req(
|
||||
"/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 SearchMultiResponse{},
|
||||
errors.New("failed to complete find/external_id request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
}
|
||||
comb := []SearchMultiResult{}
|
||||
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 SearchMultiResponse{
|
||||
SearchResponse: SearchResponse[SearchMultiResult]{
|
||||
Results: comb,
|
||||
PageFields: PageFields{
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
)
|
||||
|
||||
type ShowDetailsOptions struct {
|
||||
// TMDB ID
|
||||
ID string
|
||||
// Country (currently used for watch providers)
|
||||
Country string
|
||||
// Request params map.
|
||||
Params map[string]string
|
||||
|
||||
// If CacheContentShow should be ran or not.
|
||||
// If the caller wants to do its own caching to the db, it can use this
|
||||
// to avoid multiple calls to CacheContentShow.
|
||||
DontRunDBCache bool
|
||||
}
|
||||
|
||||
func (t *TMDB) ShowDetails(o ShowDetailsOptions) (ShowDetails, error) {
|
||||
cacheKey := cache.CreateCacheKey(
|
||||
"ShowDetails",
|
||||
o.ID,
|
||||
o.Country,
|
||||
o.Params)
|
||||
resp := new(ShowDetails)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("ShowDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req("/tv/"+o.ID, o.Params, &resp)
|
||||
if err != nil {
|
||||
slog.Error("ShowDetails: Request failed!", "error", err)
|
||||
return ShowDetails{}, errors.New("request failed")
|
||||
}
|
||||
resp.WatchProvidersTransformed = transformProviders(
|
||||
&resp.WatchProviders,
|
||||
o.Country)
|
||||
// We don't want this to linger around (in cache) since we have the
|
||||
// transformed version now..
|
||||
resp.WatchProviders = nil
|
||||
if !o.DontRunDBCache {
|
||||
go t.contentProvider.CacheContentShow(*resp, true)
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) ShowCredits(id string) (ContentCredits, error) {
|
||||
resp := new(ContentCredits)
|
||||
err := t.req("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("ShowCredits: Request failed!", "error", err)
|
||||
return ContentCredits{}, errors.New("request failed")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) SeasonDetails(
|
||||
showId string,
|
||||
seasonNumber string,
|
||||
) (SeasonDetails, error) {
|
||||
cacheKey := cache.CreateCacheKey("SeasonDetails", showId, seasonNumber)
|
||||
resp := new(SeasonDetails)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SeasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/tv/"+showId+"/season/"+seasonNumber,
|
||||
map[string]string{},
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("SeasonDetails: Request failed!", "error", err)
|
||||
return SeasonDetails{}, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
)
|
||||
|
||||
func (t *TMDB) Trending(
|
||||
ttype TrendingType,
|
||||
pageNum int,
|
||||
region string,
|
||||
) (TrendingCombined, error) {
|
||||
resp := new(TrendingCombined)
|
||||
if ttype != TrendingTypeAll &&
|
||||
ttype != TrendingTypeMovie &&
|
||||
ttype != TrendingTypeShow &&
|
||||
ttype != 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(ttype),
|
||||
region,
|
||||
pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("Trending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := t.req(
|
||||
"/trending/"+string(ttype)+"/day",
|
||||
map[string]string{
|
||||
"page": strconv.Itoa(pageNum),
|
||||
"region": region,
|
||||
},
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("Trending: Request failed!", "error", err)
|
||||
return *resp, errors.New("request failed")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
func (t *TMDB) Regions() (Regions, error) {
|
||||
resp := new(Regions)
|
||||
err := t.req(
|
||||
"/watch/providers/regions",
|
||||
map[string]string{},
|
||||
&resp)
|
||||
if err != nil {
|
||||
slog.Error("Regions: Request failed", "error", err)
|
||||
return Regions{}, errors.New("request failed")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
func LimitedReadAll(r io.Reader, maxSize int64) ([]byte, error) {
|
||||
// LimitReader: Allow reading one extra byte so our maxSize check later works.
|
||||
b, err := io.ReadAll(io.LimitReader(r, maxSize+1))
|
||||
if err != nil {
|
||||
return b, err
|
||||
}
|
||||
if int64(len(b)) > maxSize {
|
||||
return b, errors.New("file is too big")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package util
|
||||
|
||||
const (
|
||||
Byte int64 = 1
|
||||
Kibibyte int64 = 1024
|
||||
Mebibyte int64 = 1024 * Kibibyte
|
||||
)
|
||||
+17
-11
@@ -33,6 +33,7 @@ import (
|
||||
"github.com/sbondCo/Watcharr/feature/feature"
|
||||
"github.com/sbondCo/Watcharr/feature/follow"
|
||||
"github.com/sbondCo/Watcharr/feature/game"
|
||||
"github.com/sbondCo/Watcharr/feature/img"
|
||||
"github.com/sbondCo/Watcharr/feature/imprt"
|
||||
"github.com/sbondCo/Watcharr/feature/jellyfin"
|
||||
"github.com/sbondCo/Watcharr/feature/job"
|
||||
@@ -195,23 +196,29 @@ func main() {
|
||||
api := gine.Group("/api")
|
||||
br := router.NewBaseRouter(db, api, cfg)
|
||||
|
||||
t := tmdb.NewTMDB(cfg.TMDB_KEY)
|
||||
tmdbService := tmdb.NewTMDB(cfg.TMDB_KEY)
|
||||
|
||||
contentService := content.NewService(db, tmdbService)
|
||||
tmdbService.AddContentProvider(contentService)
|
||||
plexService := plex.NewService(cfg)
|
||||
authService := auth.NewService(db, cfg, plexService)
|
||||
authTrustedHeaderService := auth.NewTrustedHeaderService(db, cfg, authService)
|
||||
contentService := content.NewService(db, t)
|
||||
activityService := activity.NewService(db)
|
||||
userService := user.NewService(db)
|
||||
userManageService := user.NewManageService(db)
|
||||
gameService := game.NewService(db, &br.Cfg.TWITCH, activityService)
|
||||
watchedService := watched.NewService(db, contentService, gameService, activityService)
|
||||
watchedService := watched.NewService(
|
||||
db,
|
||||
contentService,
|
||||
gameService,
|
||||
activityService,
|
||||
userService)
|
||||
watchedSeasonService := season.NewService(db, activityService)
|
||||
watchedEpisodeService := episode.NewService(
|
||||
db,
|
||||
watchedService,
|
||||
watchedSeasonService,
|
||||
contentService,
|
||||
tmdbService,
|
||||
activityService,
|
||||
userService)
|
||||
jellyfinService := jellyfin.NewService(cfg)
|
||||
@@ -232,22 +239,22 @@ func main() {
|
||||
profileService := profile.NewService(db)
|
||||
followService := follow.NewService(db)
|
||||
tagService := tag.NewService(db, watchedService)
|
||||
searchService := search.NewService(db, br.Cfg, contentService, watchedService)
|
||||
discoverService := discover.NewService(db, br.Cfg, contentService)
|
||||
searchService := search.NewService(db, br.Cfg, tmdbService, watchedService)
|
||||
discoverService := discover.NewService(db, br.Cfg, tmdbService)
|
||||
importService := imprt.NewService(
|
||||
db,
|
||||
watchedService,
|
||||
watchedSeasonService,
|
||||
watchedEpisodeService,
|
||||
contentService,
|
||||
tmdbService,
|
||||
activityService,
|
||||
tagService,
|
||||
searchService)
|
||||
importTraktService := imprt.NewTraktService(importService)
|
||||
|
||||
auth.NewRouter(br, authService, authTrustedHeaderService).AddRoutes()
|
||||
content.NewRouter(br, contentService, watchedService).AddRoutes()
|
||||
watched.NewRouter(br, t, watchedService).AddRoutes()
|
||||
content.NewRouter(br, contentService, watchedService, tmdbService).AddRoutes()
|
||||
watched.NewRouter(br, watchedService).AddRoutes()
|
||||
season.NewRouter(br, watchedSeasonService).AddRoutes()
|
||||
episode.NewRouter(br, watchedEpisodeService).AddRoutes()
|
||||
activity.NewRouter(br, activityService).AddRoutes()
|
||||
@@ -266,6 +273,7 @@ func main() {
|
||||
game.NewRouter(br, gameService, watchedService).AddRoutes()
|
||||
search.NewRouter(br, searchService, watchedService).AddRoutes()
|
||||
discover.NewRouter(br, discoverService, watchedService).AddRoutes()
|
||||
img.NewRouter(br).AddRoutes()
|
||||
|
||||
// Only add setup routes if there are no users found in db.
|
||||
var userCount int64
|
||||
@@ -281,8 +289,6 @@ func main() {
|
||||
"error", uresp.Error)
|
||||
}
|
||||
|
||||
api.Static("/img", path.Join(config.DataPath, "img"))
|
||||
|
||||
go taskl.SetupTasks(cfg, db)
|
||||
|
||||
gine.Run("0.0.0.0:3080")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/logo-col.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
|
||||
Vendored
@@ -17,7 +17,7 @@
|
||||
let { activity = undefined, onRemoved }: Props = $props();
|
||||
|
||||
let clickedActivity: Activity | undefined = $state();
|
||||
let groupedActivities: { [index: string]: any } = $derived(
|
||||
let groupedActivities: { [index: string]: Activity[] } = $derived(
|
||||
getGroupedActivity(activity),
|
||||
);
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
const a = activities?.sort(
|
||||
(a, b) => getCreatedAtVis(b) - getCreatedAtVis(a),
|
||||
);
|
||||
let grouped: { [index: string]: any } = {};
|
||||
let grouped: { [index: string]: Activity[] } = {};
|
||||
if (a) {
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
const activity = a[i];
|
||||
@@ -231,10 +231,10 @@
|
||||
<h2>Activity</h2>
|
||||
{#if groupedActivities && Object.keys(groupedActivities).length > 0}
|
||||
<ul>
|
||||
{#each Object.keys(groupedActivities) as k}
|
||||
{#each Object.keys(groupedActivities) as k (k)}
|
||||
<h3>{k}</h3>
|
||||
|
||||
{#each groupedActivities[k] as a}
|
||||
{#each groupedActivities[k] as a (a.id)}
|
||||
{@const d = new Date(getCreatedAtVis(a))}
|
||||
<li>
|
||||
<button class="plain" onclick={() => openEditor(a)}>
|
||||
|
||||
+6
-18
@@ -36,29 +36,17 @@
|
||||
|
||||
const pressedLetter = event.key.toLowerCase();
|
||||
|
||||
// Filter options that start with the pressed letter (ignoring case)
|
||||
const filteredOptions = options.filter((o) =>
|
||||
typeof o === "string"
|
||||
? o.toLowerCase().startsWith(pressedLetter)
|
||||
: o.value.toLowerCase().startsWith(pressedLetter),
|
||||
);
|
||||
|
||||
// If there are filtered options, select the first one
|
||||
let f: string | number | undefined;
|
||||
if (filteredOptions.length > 0) {
|
||||
f =
|
||||
typeof filteredOptions[0] === "string"
|
||||
? filteredOptions[0]
|
||||
: filteredOptions[0].id;
|
||||
}
|
||||
|
||||
// Find first button with text content starting with letter pressed and scroll it into view.
|
||||
const btns = ulElement?.querySelectorAll("button");
|
||||
if (btns) {
|
||||
for (let i = 0; i < btns.length; i++) {
|
||||
const btn = btns[i];
|
||||
if (btn.textContent?.toLowerCase()?.startsWith(pressedLetter)) {
|
||||
btn.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
btn.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -98,7 +86,7 @@
|
||||
<Icon i="chevron" facing={open ? "up" : "down"} />
|
||||
</button>
|
||||
<ul bind:this={ulElement}>
|
||||
{#each showActiveElementsInOptions ? options : options.filter( (o) => (typeof o === "string" ? o !== active : o.id !== active), ) as o}
|
||||
{#each showActiveElementsInOptions ? options : options.filter( (o) => (typeof o === "string" ? o !== active : o.id !== active) ) as o (typeof o === "string" ? o : o.id)}
|
||||
<li>
|
||||
<button
|
||||
class="plain"
|
||||
|
||||
@@ -17,18 +17,18 @@
|
||||
allowSelectMultipleFiles = false,
|
||||
}: Props = $props();
|
||||
|
||||
let fileInput: HTMLInputElement = $state();
|
||||
let dragEnterTarget: EventTarget | null = $state();
|
||||
let fileInput: HTMLInputElement | undefined = $state();
|
||||
let dragEnterTarget: EventTarget | null | undefined = $state();
|
||||
let isDragOver = $state(false);
|
||||
|
||||
function importFile() {
|
||||
fileInput.click();
|
||||
fileInput?.click();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (fileInput) {
|
||||
fileInput.addEventListener("change", () => {
|
||||
filesSelected(fileInput.files);
|
||||
filesSelected(fileInput?.files);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ReqerError } from "./util/fetch";
|
||||
|
||||
interface Props {
|
||||
pretty: string;
|
||||
error: any;
|
||||
error: unknown;
|
||||
onRetry?: () => void | undefined;
|
||||
}
|
||||
|
||||
@@ -12,10 +14,10 @@
|
||||
<div>
|
||||
<div class="error-text">
|
||||
<strong>{pretty}</strong>
|
||||
{#if error?.message}
|
||||
{#if error instanceof Error && error.message}
|
||||
<p>{error.message}</p>
|
||||
{#if error.response?.data?.error}
|
||||
<p>{error.response.data.error}</p>
|
||||
{#if error instanceof ReqerError && error?.body?.error}
|
||||
<p>{error.body.error}</p>
|
||||
{/if}
|
||||
{:else}
|
||||
<p>{JSON.stringify(error)}</p>
|
||||
|
||||
@@ -674,6 +674,7 @@
|
||||
viewBox="0 0 512 512"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M368 192h-16v-80a96 96 0 10-192 0v80h-16a64.07 64.07 0 00-64 64v176a64.07 64.07 0 0064 64h224a64.07 64.07 0 0064-64V256a64.07 64.07 0 00-64-64zm-48 0H192v-80a64 64 0 11128 0z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -3,14 +3,10 @@
|
||||
import Modal from "@/lib/Modal.svelte";
|
||||
import Spinner from "@/lib/Spinner.svelte";
|
||||
import { notify } from "@/lib/util/notify";
|
||||
import {
|
||||
JobStatus,
|
||||
type GetJobResponse,
|
||||
type JobCreatedResponse,
|
||||
} from "@/types";
|
||||
import axios from "axios";
|
||||
import { JobStatus, type GetJobResponse } from "@/types";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { store } from "@/store.svelte";
|
||||
import { req } from "./util/api";
|
||||
import { ReqerError } from "./util/fetch";
|
||||
|
||||
interface Props {
|
||||
modalTitle: string;
|
||||
@@ -43,10 +39,10 @@
|
||||
jobId = r.jobId;
|
||||
step = "job-running";
|
||||
startJobWatcher();
|
||||
} catch (err: any) {
|
||||
} catch (err) {
|
||||
console.error("startSync failed!", err);
|
||||
step = "errored";
|
||||
jobFailError = err?.response ? err?.response?.data?.error : String(err);
|
||||
jobFailError = ReqerError.getMsg(err, "Starting sync failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +59,13 @@
|
||||
let seqfailedJobReqs = 0;
|
||||
while (step === "job-running") {
|
||||
try {
|
||||
const r = await axios.get<GetJobResponse>(`/job/${jobId}`);
|
||||
console.log("jobWatcher: Got job data:", r.data);
|
||||
latestJobStatus = r.data;
|
||||
currentTask = r.data?.currentTask;
|
||||
if (r.data?.status === JobStatus.DONE) {
|
||||
const r = await req.get<GetJobResponse>(`/job/${jobId}`);
|
||||
console.log("jobWatcher: Got job data:", r);
|
||||
latestJobStatus = r;
|
||||
currentTask = r?.currentTask;
|
||||
if (r?.status === JobStatus.DONE) {
|
||||
step = "done";
|
||||
} else if (r.data?.status === JobStatus.CANCELLED) {
|
||||
} else if (r?.status === JobStatus.CANCELLED) {
|
||||
step = "errored";
|
||||
}
|
||||
// If we get here without erroring, we can reset it to 0.
|
||||
@@ -94,31 +90,6 @@
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
if (step !== "modal-closing") {
|
||||
// Update our watched list
|
||||
const nid = notify({
|
||||
text: "Fetching updated watched list.",
|
||||
type: "loading",
|
||||
});
|
||||
try {
|
||||
const w = await axios.get("/watched");
|
||||
if (w?.data?.length > 0) {
|
||||
store.watchedList = w.data;
|
||||
}
|
||||
notify({
|
||||
id: nid,
|
||||
text: "Fetched updated watched list.",
|
||||
type: "success",
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("jobWatcher: Getting updated watched list failed!", err);
|
||||
notify({
|
||||
id: nid,
|
||||
text: "Getting updated watched list failed!",
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function modalClose() {
|
||||
@@ -175,7 +146,7 @@
|
||||
</h4>
|
||||
<span>Job finished, but with errors:</span>
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
{#each latestJobStatus?.errors as e (e)}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
@@ -191,7 +162,7 @@
|
||||
{/if}
|
||||
{#if latestJobStatus?.errors && latestJobStatus?.errors?.length > 0}
|
||||
<ul>
|
||||
{#each latestJobStatus?.errors as e}
|
||||
{#each latestJobStatus?.errors as e (e)}
|
||||
<li>{e}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<span>{allCheckBox}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#each options as o}
|
||||
{#each options as o (o.id)}
|
||||
<div>
|
||||
<Checkbox
|
||||
name={o.displayValue}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { store } from "@/store.svelte";
|
||||
import SpinnerTiny from "./SpinnerTiny.svelte";
|
||||
import { unNotify } from "./util/notify";
|
||||
import Icon from "./Icon.svelte";
|
||||
</script>
|
||||
|
||||
<div id="notifications">
|
||||
{#each store.notifications as n (n.id)}
|
||||
<div class={`${n.type} notif`}>
|
||||
{#if n.type === "loading"}
|
||||
<SpinnerTiny />
|
||||
{/if}
|
||||
<!-- only comes from our strings (which may have html) -->
|
||||
<!-- eslint-disable-next-line -->
|
||||
<span>{@html n.text}</span>
|
||||
<button
|
||||
class="plain"
|
||||
onclick={() => {
|
||||
unNotify(n.id);
|
||||
}}
|
||||
>
|
||||
<Icon i="close" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
#notifications {
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
gap: 10px;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
z-index: 99999;
|
||||
|
||||
.notif {
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
min-width: 200px;
|
||||
color: black;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba($color: #000000, $alpha: 0.2);
|
||||
box-shadow: 0 4px 10px rgba($color: #000000, $alpha: 0.2);
|
||||
animation: comein 250ms ease forwards;
|
||||
position: relative;
|
||||
|
||||
&.loading {
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
@keyframes comein {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: white;
|
||||
background-color: $error;
|
||||
border: 1px solid $error;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: white;
|
||||
background-color: $success;
|
||||
border: 1px solid $success;
|
||||
|
||||
span {
|
||||
border-color: rgba($color: white, $alpha: 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid rgba($color: black, $alpha: 0.2);
|
||||
padding: 10px 12px;
|
||||
padding-left: 9px;
|
||||
|
||||
a {
|
||||
color: white;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 8px;
|
||||
width: 22px;
|
||||
height: 100%;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import axios from "axios";
|
||||
import DropDown from "./DropDown.svelte";
|
||||
import type { DropDownItem, TMDBRegions } from "@/types";
|
||||
import Error from "./Error.svelte";
|
||||
import { req } from "./util/api";
|
||||
|
||||
interface Props {
|
||||
selectedCountry?: string;
|
||||
@@ -16,10 +16,10 @@
|
||||
onChange,
|
||||
}: Props = $props();
|
||||
|
||||
let mappedCountries: DropDownItem[] = $state();
|
||||
let mappedCountries: DropDownItem[] = $state([]);
|
||||
|
||||
async function getCountries() {
|
||||
const c = (await axios.get(`/content/regions`)).data as TMDBRegions;
|
||||
const c = await req.get<TMDBRegions>(`/content/regions`);
|
||||
mappedCountries = c.results
|
||||
.map((cc) => {
|
||||
return {
|
||||
|
||||
+40
-16
@@ -1,23 +1,32 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
style?: string;
|
||||
}
|
||||
|
||||
let { style = "" }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="loader" {style}></div>
|
||||
<span class="loader">
|
||||
<svg viewBox="0 0 50 50">
|
||||
<circle
|
||||
cx="25"
|
||||
cy="25"
|
||||
r="20"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="6"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
<style lang="scss">
|
||||
.loader {
|
||||
$animDur: 1.4s;
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 12px;
|
||||
border: 2px solid $text-color;
|
||||
border-bottom-color: transparent;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
animation: rotation 1s linear infinite;
|
||||
height: 16px;
|
||||
animation: rotation $animDur linear infinite;
|
||||
|
||||
svg {
|
||||
circle {
|
||||
stroke-dasharray: 80px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
animation: circle-size $animDur ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
@@ -28,4 +37,19 @@
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes circle-size {
|
||||
0% {
|
||||
stroke-dasharray: 1px 200px;
|
||||
stroke-dashoffset: 0px;
|
||||
}
|
||||
50% {
|
||||
stroke-dasharray: 100px 200px;
|
||||
stroke-dashoffset: -15px;
|
||||
}
|
||||
100% {
|
||||
stroke-dasharray: 1px 200px;
|
||||
stroke-dashoffset: -124px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import type { PublicUser } from "@/types";
|
||||
|
||||
interface Props {
|
||||
@@ -11,9 +12,9 @@
|
||||
<div>
|
||||
<h2>Users</h2>
|
||||
<ul>
|
||||
{#each users as user}
|
||||
{#each users as user (user.id)}
|
||||
<li title={user.username}>
|
||||
<a href="/lists/{user.id}/{user.username}">
|
||||
<a href={resolve(`/lists/${user.id}/${user.username}`)}>
|
||||
<span>{user.username}</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default function tooltip(node: HTMLElement, opts: ToolTipOptions) {
|
||||
return {
|
||||
update(opts: ToolTipOptions) {
|
||||
text = opts.text;
|
||||
pos = opts.pos || "left";
|
||||
condition = opts.condition ?? true;
|
||||
},
|
||||
destroy() {
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import type { ContentType, PublicUser, WatchedStatus } from "@/types";
|
||||
import HorizontalList from "../HorizontalList.svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import Error from "../Error.svelte";
|
||||
import Icon from "../Icon.svelte";
|
||||
import { watchedStatuses } from "../util/helpers";
|
||||
import { req } from "../util/api";
|
||||
|
||||
interface FollowThoughts {
|
||||
followedUser: PublicUser;
|
||||
@@ -26,11 +26,9 @@
|
||||
let modalShownFor: FollowThoughts | undefined = $state(undefined);
|
||||
|
||||
async function getFollowsThoughts() {
|
||||
return (
|
||||
await axios.get<FollowThoughts[]>(
|
||||
`/follow/thoughts/${mediaType}/${mediaId}`,
|
||||
)
|
||||
).data;
|
||||
return await req.get<FollowThoughts[]>(
|
||||
`/follow/thoughts/${mediaType}/${mediaId}`,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -39,7 +37,7 @@
|
||||
{:then fts}
|
||||
{#if fts?.length > 0}
|
||||
<HorizontalList title="Followed Thoughts">
|
||||
{#each fts as ft}
|
||||
{#each fts as ft (ft.followedUser.id)}
|
||||
<button
|
||||
class={["thoughts-card plain", ft.thoughts ? "" : "no-thoughts"].join(
|
||||
" ",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import type { MediaGenre } from "@/types";
|
||||
|
||||
interface Props {
|
||||
genres?: MediaGenre[];
|
||||
}
|
||||
|
||||
let { genres }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if genres && genres.length > 0}
|
||||
{#each genres as g, i (g.id)}
|
||||
<span>
|
||||
{g.name}{i !== genres.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Generic "unknown" text, since this component
|
||||
is also used for displaying Game Modes. -->
|
||||
<span>Unknown</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
</style>
|
||||
@@ -66,8 +66,7 @@
|
||||
placeholder={`My thoughts on ${contentTitle}`}
|
||||
value={thoughts}
|
||||
bind:this={textarea}
|
||||
oninput={resizeTextarea}
|
||||
></textarea>
|
||||
oninput={resizeTextarea}></textarea>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Steam"}
|
||||
<a aria-label={i} {href} target="_blank">
|
||||
<a aria-label={i} {href} rel="external" target="_blank">
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
@@ -177,7 +177,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "GOG"}
|
||||
<a aria-label={i} {href} target="_blank">
|
||||
<a aria-label={i} {href} rel="external" target="_blank">
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
@@ -190,7 +190,7 @@
|
||||
</svg>
|
||||
</a>
|
||||
{:else if i === "Itch"}
|
||||
<a aria-label={i} {href} target="_blank">
|
||||
<a aria-label={i} {href} rel="external" target="_blank">
|
||||
<svg
|
||||
width="30"
|
||||
height="30"
|
||||
|
||||
@@ -13,14 +13,16 @@
|
||||
|
||||
{#if providers?.length > 0}
|
||||
<div class="streaming-providers">
|
||||
{#each providers as provider}
|
||||
{#each providers as provider (provider)}
|
||||
<ProviderIcon i={provider.name} href={provider.link} wh={40} />
|
||||
{/each}
|
||||
{#if fullListLink}
|
||||
<!-- The fullListLink is important for TMDB data, we always show it
|
||||
as "JustWatch" (set in component prop) because that data requires
|
||||
attribution! but also it helps support tmdb in some way. -->
|
||||
<a href={fullListLink} target="_blank">{fullListLinkText}</a>
|
||||
<a href={fullListLink} rel="external" target="_blank">
|
||||
{fullListLinkText}
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
{#if similar?.length > 0}
|
||||
<HorizontalList title="Similar">
|
||||
{#each similar as content, i}
|
||||
{#each similar as content, i (content.ids)}
|
||||
<Poster media={content} small={true} bind:watched={similar[i].watched} />
|
||||
{/each}
|
||||
</HorizontalList>
|
||||
|
||||
@@ -1,34 +1,48 @@
|
||||
<script lang="ts">
|
||||
import { dateValid } from "../util/date";
|
||||
|
||||
interface Props {
|
||||
homepage?: string;
|
||||
title?: string;
|
||||
releaseDate?: Date;
|
||||
endDate?: Date;
|
||||
voteAverage?: number;
|
||||
voteCount?: number;
|
||||
}
|
||||
|
||||
let { homepage, title, releaseDate, voteAverage, voteCount }: Props =
|
||||
let { homepage, title, releaseDate, endDate, voteAverage, voteCount }: Props =
|
||||
$props();
|
||||
|
||||
// if voteAvg bigger than 10, it is out of 100, so no need to * by 10
|
||||
const vote = voteAverage
|
||||
? Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10
|
||||
: 0;
|
||||
const vote = $derived(
|
||||
voteAverage
|
||||
? Math.round(voteAverage > 10 ? voteAverage : voteAverage * 10) / 10
|
||||
: 0,
|
||||
);
|
||||
const titleSafe = $derived(title ? title : "Unknown Title");
|
||||
const releaseYear = $derived(
|
||||
releaseDate ? releaseDate.getFullYear() : undefined,
|
||||
dateValid(releaseDate) ? releaseDate.getFullYear() : undefined,
|
||||
);
|
||||
const endYear = $derived(
|
||||
dateValid(endDate) ? endDate.getFullYear() : undefined,
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="title-container">
|
||||
<span class="title">
|
||||
{#if homepage}
|
||||
<a href={homepage} target="_blank">{titleSafe}</a>
|
||||
<a href={homepage} rel="external" target="_blank">{titleSafe}</a>
|
||||
{:else}
|
||||
<span class="t">{titleSafe}</span>
|
||||
{/if}
|
||||
{#if releaseYear}
|
||||
<span class="year">{releaseYear}</span>
|
||||
<span class="year">
|
||||
<!--First span ends on the line with the #if so there's no whitespace-->
|
||||
<span title={releaseDate?.toLocaleDateString()}>{releaseYear}</span
|
||||
>{#if endYear && endYear != releaseYear}
|
||||
<span title={endDate?.toLocaleDateString()}>-{endYear}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from "$app/paths";
|
||||
import type { TMDBContentCreditsCrew } from "@/types";
|
||||
|
||||
interface Props {
|
||||
@@ -9,9 +10,9 @@
|
||||
</script>
|
||||
|
||||
<div class="creators">
|
||||
{#each topCrew as crew}
|
||||
{#each topCrew as crew (crew.credit_id)}
|
||||
<div>
|
||||
<a href={`/person/${crew.id}`}>{crew.name}</a>
|
||||
<a href={resolve(`/person/${crew.id}`)}>{crew.name}</a>
|
||||
<span>{crew.job}</span>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
let { embed, closed }: Props = $props();
|
||||
|
||||
let modalDiv: HTMLDivElement = $state();
|
||||
let modalDiv: HTMLDivElement | undefined = $state();
|
||||
|
||||
onMount(() => {
|
||||
// For better experience on keyboard.
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
return a.best ? -1 : 1;
|
||||
});
|
||||
if (t[0]?.id) {
|
||||
return `https://www.youtube.com/embed/${t[0]?.id}`;
|
||||
return `https://www.youtube-nocookie.com/embed/${t[0]?.id}`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
bind:this={avatarInput}
|
||||
type="file"
|
||||
title=""
|
||||
accept=".jpg,.png,.gif,.webp"
|
||||
accept=".jpg,.png,.gif"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import Modal from "../Modal.svelte";
|
||||
import axios from "axios";
|
||||
import type { TrustedHeaderAuthLogoutDetailsResponse } from "@/types";
|
||||
import Spinner from "../Spinner.svelte";
|
||||
import { clearWatcharrData } from ".";
|
||||
import { goto } from "$app/navigation";
|
||||
import { req } from "../util/api";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
@@ -18,10 +19,10 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const r = await axios.get<TrustedHeaderAuthLogoutDetailsResponse>(
|
||||
const r = await req.get<TrustedHeaderAuthLogoutDetailsResponse>(
|
||||
"/auth/proxy_logout_details",
|
||||
);
|
||||
logoutUrl = r?.data?.logoutUrl;
|
||||
logoutUrl = r?.logoutUrl;
|
||||
if (!logoutUrl?.toLowerCase()?.startsWith("http")) {
|
||||
// If no protocol in logoutUrl, set https
|
||||
logoutUrl = `https://${logoutUrl}`;
|
||||
@@ -34,7 +35,7 @@
|
||||
|
||||
function logout() {
|
||||
clearWatcharrData();
|
||||
goto("/login?noAuto=1");
|
||||
goto(resolve("/login?noAuto=1"));
|
||||
}
|
||||
|
||||
function proxyLogout() {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { clearWatcharrData } from "../logout";
|
||||
import { notify } from "../util/notify";
|
||||
import AboutModal from "./AboutModal.svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
let user = $derived(store.userInfo);
|
||||
let proxyUserLogoutShown = $state(false);
|
||||
@@ -20,23 +21,23 @@
|
||||
return;
|
||||
}
|
||||
clearWatcharrData();
|
||||
goto("/login");
|
||||
goto(resolve("/login"));
|
||||
}
|
||||
|
||||
function profile() {
|
||||
goto("/profile");
|
||||
goto(resolve("/profile"));
|
||||
}
|
||||
|
||||
function serverSettings() {
|
||||
goto("/server");
|
||||
goto(resolve("/server"));
|
||||
}
|
||||
|
||||
function userManagement() {
|
||||
goto("/manage_users");
|
||||
goto(resolve("/manage_users"));
|
||||
}
|
||||
|
||||
function requestManagement() {
|
||||
goto("/arr_requests");
|
||||
goto(resolve("/arr_requests"));
|
||||
}
|
||||
|
||||
function shareWatchedList() {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { store } from "@/store.svelte";
|
||||
import Menu from "../Menu.svelte";
|
||||
import { resolve } from "$app/paths";
|
||||
|
||||
interface Props {
|
||||
close: () => {};
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
let { close }: Props = $props();
|
||||
@@ -13,9 +14,11 @@
|
||||
{#if store.follows?.length > 0}
|
||||
<h4 class="norm sm-caps">following</h4>
|
||||
<div class="list">
|
||||
{#each store.follows as f}
|
||||
{#each store.follows as f (f.followedUser.id)}
|
||||
<a
|
||||
href="/lists/{f.followedUser.id}/{f.followedUser.username}"
|
||||
href={resolve(
|
||||
`/lists/${f.followedUser.id}/${f.followedUser.username}`,
|
||||
)}
|
||||
onclick={() => close()}
|
||||
>
|
||||
{f.followedUser.username}
|
||||
|
||||
@@ -21,41 +21,51 @@
|
||||
}
|
||||
store.activeSort = [type, mode];
|
||||
}
|
||||
|
||||
function getDirectionClass(sort: string): string {
|
||||
if (store.activeSort[0] !== sort) {
|
||||
return "";
|
||||
}
|
||||
if (store.activeSort[1]) {
|
||||
return store.activeSort[1].toLowerCase();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
</script>
|
||||
|
||||
<Menu conf={{ width: "180px", right: "90px", arrowLeft: "21px" }}>
|
||||
<button
|
||||
class={`plain ${store.activeSort[0] == "DATEADDED" ? store.activeSort[1].toLowerCase() : ""}`}
|
||||
class={`plain ${getDirectionClass("DATEADDED")}`}
|
||||
onclick={() => sortClicked("DATEADDED")}
|
||||
>
|
||||
Date Added
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.activeSort[0] == "LASTCHANGED" ? store.activeSort[1].toLowerCase() : ""}`}
|
||||
class={`plain ${getDirectionClass("LASTCHANGED")}`}
|
||||
onclick={() => sortClicked("LASTCHANGED")}
|
||||
>
|
||||
Last Changed
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.activeSort[0] == "LASTFIN" ? store.activeSort[1].toLowerCase() : ""}`}
|
||||
class={`plain ${getDirectionClass("LASTFIN")}`}
|
||||
onclick={() => sortClicked("LASTFIN")}
|
||||
>
|
||||
Last Finished
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.activeSort[0] == "RATING" ? store.activeSort[1].toLowerCase() : ""}`}
|
||||
class={`plain ${getDirectionClass("RATING")}`}
|
||||
onclick={() => sortClicked("RATING")}
|
||||
>
|
||||
Rating
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.activeSort[0] == "ALPHA" ? store.activeSort[1].toLowerCase() : ""}`}
|
||||
class={`plain ${getDirectionClass("ALPHA")}`}
|
||||
onclick={() => sortClicked("ALPHA")}
|
||||
>
|
||||
Alphabetical
|
||||
</button>
|
||||
<button
|
||||
class={`plain ${store.activeSort[0] == "DATERELEASED" ? store.activeSort[1].toLowerCase() : ""}`}
|
||||
class={`plain ${getDirectionClass("DATERELEASED")}`}
|
||||
onclick={() => sortClicked("DATERELEASED")}
|
||||
>
|
||||
Release Date
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user