Compare commits

..

146 Commits

Author SHA1 Message Date
IRHM fa34f1ead1 v4.2.1 CHANGELOG 2026-08-04 02:01:51 +01:00
IRHM cefed53c7f v4.2.1 CHANGELOG 2026-08-04 02:00:29 +01:00
IRHM 0d8b050615 v4.2.1 2026-08-04 01:36:44 +01:00
IRHM 63988f833f v4.2.1-dev1 2026-08-04 01:12:56 +01:00
IRHM e391d5dc69 (fix) ProvidersList: Duplicate key error. 2026-08-04 00:11:20 +00:00
IRHM cba3d11f64 v4.2.0 CHANGELOG 2026-08-03 09:02:17 +01:00
IRHM 5d95aa4b41 v4.2.0 2026-08-03 08:39:05 +01:00
IRHM 41dff624bb README: Add link to FEATURES document. 2026-08-03 07:40:44 +01:00
IRHM 39a77b4428 v4.2.0-dev2 2026-08-03 07:05:06 +01:00
IRHM 9b74728afb SeasonsListEpisode: Fix info box alignment
Also fixed the episode rating text getting cut off at certain dimensions by enforcing its minimum width at which a double digit rating is still visible (eg 7.1)
2026-08-03 07:04:06 +01:00
IRHM 113ccbbbfd v4.2.0-dev1 2026-08-03 03:30:47 +01:00
IRHM 87033f423d upgrade server dependencies 2026-08-03 02:25:54 +00:00
IRHM 3a52a9a592 Update FEATURES.md 2026-08-03 01:14:02 +00:00
IRHM 9b39617650 search: Add filters help button opening a modal with examples 2026-08-03 01:14:02 +00:00
IRHM dd1503846d igdb: SearchOptions: Add note comment 2026-08-03 01:14:02 +00:00
IRHM 92edb75de9 Games: Support searching with Year or FirstYear filters 2026-08-03 01:14:02 +00:00
IRHM dc2b9a1a63 tmdb: Remove olf *WithWatched structs
These are not used and are a remnant from before the server restructure (it was another solution i was cooking up, but didn't end up going with).
2026-08-03 01:14:02 +00:00
IRHM 7b65c8af0f tmdb: Strip TMDB prefix from all struct names
This prefix is a remnant from when the structs where in the "main" package (from before the server refactor).
2026-08-03 01:14:02 +00:00
IRHM cf3168d59c content: GetOrCacheContent: Use Movie/ShowDetails funcs instead of manually using APIRequest func
Because this makes more sense to reuse the logic and also because APIRequest is now only locally accessible in the tmdb package, to stop me from using anywhere else, should always use an exposed function from the package so it can be reused.
2026-08-03 01:14:02 +00:00
IRHM fb1017f551 tmdb: Make req local, Movie/Show details funcs now accept struct options 2026-08-03 01:14:02 +00:00
IRHM bff354fcc3 organize tmdb api request logic into tmdb package AND support inline search filters 2026-08-03 01:14:02 +00:00
IRHM 0be6fdb346 content: Improve search function formatting 2026-08-03 01:14:02 +00:00
IRHM a73651808e Update CHANGELOG.md 2026-08-01 03:48:44 +00:00
IRHM 9da0913d48 Add Status & ReleaseDateLast to Media, show end year for tv shows 2026-08-01 03:48:44 +00:00
IRHM 0a38171836 doc: installation/for-development: update outdated commands
we switched to using make a while ago, forgot to update this doc
2026-07-30 22:50:38 +01:00
IRHM d009528388 Update CHANGELOG.md 2026-07-30 21:33:41 +00:00
IRHM cd34ecba61 changelog: correct the new stuff and put it under new instead of changed 2026-07-30 21:33:41 +00:00
IRHM 769e30c9c7 import: Support rating column decimals 2026-07-30 21:33:41 +00:00
IRHM ba961bf5c3 Update CHANGELOG.md 2026-07-30 21:33:41 +00:00
IRHM efddcaed07 doc: importing/text-file: Update to show support for providing a rating 2026-07-30 21:33:41 +00:00
IRHM 76fdbe3cd0 import: Remove "Try refreshing" text from the successfully imported notification
It is no longer very pertinent since all watched data isn't stored in the browser. When we redirect the user to their list, fresh data is already fetched.
2026-07-30 21:33:41 +00:00
IRHM f4b7f66104 import->text list: Support providing a rating in square brackets 2026-07-30 21:33:41 +00:00
IRHM 12ceb13bcb import: Add rating column (validated to be 0-10) and set import item state to FAILED if request fails 2026-07-30 21:33:41 +00:00
IRHM 5a5ae0ad83 Show Aired Date for episodes. 2026-07-30 03:46:29 +00:00
IRHM 05e24e42c6 Show air date with toRelativeDate, hide star when unaired and no votes, make airDate logic a bit more robust 2026-07-30 03:32:32 +00:00
mkarpats e5d93fd8a2 feat: Display air date for unaired episodes
- Show air date for episodes that are in the future.
2026-07-30 03:32:32 +00:00
IRHM bf5d44f404 fix lock closed color dark theme 2026-07-30 02:42:35 +00:00
IRHM 5abbb47fbf only show PosterImage in media pages if we have a extPosterPath 2026-07-30 02:42:35 +00:00
IRHM 40b54c1065 Fix crew each to use unique key 2026-07-30 02:42:35 +00:00
IRHM 3967a3f13f api: Update prod baseURL to be absolute
Only an absolute baseURL will work with our new Reqer class.
2026-07-30 02:42:35 +00:00
IRHM 1f4b917661 docker-compose.dev.yml: Change container name and port to be different than prod container test 2026-07-30 02:42:35 +00:00
IRHM 2202b11e39 fix all linting issues and upgrade the rest of devDependencies
now i should probably test the whole app waaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaauuuuuuuuuugugghghghghghghghghg
2026-07-30 02:42:35 +00:00
IRHM d32a1ecb39 format files (new prettier version changes some formatting)
I'm upset that the textarea closing tag is being put on the same line instead of new lined, but I couldn't figure out what changed, so I'll cry in silence and hope it gets fixed one day.
2026-07-30 02:42:35 +00:00
IRHM 347113be68 ESLint: Migrate to flat config 2026-07-30 02:42:35 +00:00
IRHM 68150b38ec fix lint script - only look in src folder 2026-07-30 02:42:35 +00:00
IRHM dc9e7e9309 test-pr-server: Correct go-version-file path? 2026-07-30 02:42:35 +00:00
IRHM 6457146214 Workflows: test-pr-server: Set go-version-file for setup-go action. 2026-07-30 02:42:35 +00:00
IRHM baba8c8c60 Dockerfile: Upgrade node steps to use version 24. 2026-07-30 02:42:35 +00:00
IRHM 31ddcacf47 Workflows: Upgrade action versions and use node 24. 2026-07-30 02:42:35 +00:00
IRHM be94f1d2b7 Replace axios with native Fetch API & some maintenance 2026-07-30 02:42:35 +00:00
IRHM d7449642ba v4.1.1 changelog 2026-07-26 03:26:00 +01:00
IRHM dbc8b74893 v4.1.1 2026-07-26 02:47:48 +01:00
IRHM 9d5e489675 plex: Stop after 10 redirects
Since the default CheckRedirect has been replaced with ours, we won't get the default protection of erroring after 10 redirects, so i've copied that back in from the std implementation
2026-07-26 01:27:30 +00:00
tonghuaroot 4e6e71687b fix(plex): strip X-Plex-Token on cross-host redirect
The Plex integration used bare http.Client values with no CheckRedirect
policy, so they followed redirects automatically. net/http strips the
standard sensitive headers on a cross-host redirect but not custom-named
headers, so the Plex token carried in X-Plex-Token was forwarded verbatim
to any host the configured PLEX_HOST redirected to, disclosing the
credential.

Route all Plex outbound calls through a shared client whose CheckRedirect
policy deletes X-Plex-Token when the redirect target host differs from the
original request host. Includes a regression test.

Signed-off-by: tonghuaroot <tonghuaroot@gmail.com>
2026-07-26 01:27:30 +00:00
IRHM 8c8d604bc6 v4.1.1-dev1 2026-07-26 01:34:45 +01:00
IRHM 39b82335eb Plex: Add Accept: "application/json" header so plex returns json again
I think the plex api default used to be JSON, which is why it worked, but I guess they changed it to XML. Adding this header asks for JSON response again.
2026-07-26 00:33:23 +00:00
IRHM 217be719e4 Revert "README: Open demo/track links in new tab"
This reverts commit 0da32164a3.
2026-07-21 09:38:20 +01:00
IRHM 0da32164a3 README: Open demo/track links in new tab 2026-07-21 09:36:49 +01:00
IRHM db72d05d79 README: Add instructions to demo instance login
I'm sure the extra clarity may be appreciated
2026-07-21 09:31:49 +01:00
IRHM 88a7542064 update readme and contributing text 2026-07-21 09:20:33 +01:00
IRHM 19c773976d CHANGELOG: v4.1.0 2026-07-19 14:40:44 +01:00
IRHM a16c769273 v4.1.0 2026-07-19 14:15:42 +01:00
IRHM f022dc702c v4.1.0-dev1 2026-07-18 13:05:20 +01:00
IRHM c2f72dbd9c Remove dependabot.yml. 2026-07-18 12:58:30 +01:00
IRHM d777630e84 CHANGELOG: Add credit to SeasonsListEpisode change 2026-07-18 12:51:03 +01:00
IRHM f5bfa29877 SeasonsListEpisode: Show spoilers when status is set to FINISHED and allow changing status while spoilers are hidden
Based on PR by goestav: https://github.com/sbondCo/Watcharr/pull/1055

Different in these ways:
- Using $derived here for `ws` variable
- Using z-index to allow status button to be clicked while spoilers are hidden
- Don't hide spoilers again (if they are shown) when episode is deleted

Also refactored `handleStatusClick()` a bit by removing the redundant `ws` lookup (it can now just use `we` set at the top of the component).

Co-Authored-By: Goestav <27970303+goestav@users.noreply.github.com>
2026-07-18 11:48:52 +00:00
IRHM 9b64932af1 watched_refine: refineFilterStatus: Remove parenthesis around WHERE query.
It looks like GORM already adds the parenthesis around this for us in the final query, so ours was unnecessary
2026-07-18 09:50:53 +00:00
IRHM bf0594c1e8 Fix Include Previously Watched regression and update profile stats to not use the setting
Index also added to

Fixes https://github.com/sbondCo/Watcharr/issues/1027
2026-07-18 09:50:53 +00:00
IRHM fee82df59d CHANGELOG: v4.0.1 2026-07-16 10:29:15 +01:00
IRHM 61382de4d2 v4.0.1 2026-07-16 10:07:58 +01:00
IRHM e65ba9d1d9 v4.0.1-dev1 2026-07-16 09:40:47 +01:00
IRHM 6654d85448 testing workflow - no longer testing workflow cuz it works now yippee 2026-07-16 08:36:22 +00:00
IRHM eabdb47cfa testing workflow - gofmt 2026-07-16 08:36:22 +00:00
IRHM 643ca0fd38 testing workflow - go test 2026-07-16 08:36:22 +00:00
IRHM a08b19675a test-pr-server.yml: Set working directory to server to fix commands not running against our go code 2026-07-16 08:36:22 +00:00
IRHM 38fba0ac10 Watched: Also log the error on the "failed to restore existing watched entry" log. 2026-07-16 08:36:22 +00:00
IRHM f23552c170 Use an svg for the favicon and change its fill to white for dark themed browsers. 2026-07-16 08:36:22 +00:00
IRHM 448fdd9df3 SpinnerTiny: Fix circle animation appearing to skip 2026-07-16 08:36:22 +00:00
IRHM ca428327e2 Move notifications to its own component. 2026-07-16 08:36:22 +00:00
IRHM f7cca74a41 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).
also: Fix color being wrong on dark theme.

svg fix inspired by mui :)
2026-07-16 08:36:22 +00:00
IRHM 86286b967f workflows/test-pr-server: Add test step 2026-07-16 08:36:22 +00:00
IRHM 8243838bfb Update CHANGELOG.md 2026-07-11 03:55:10 +01:00
IRHM 5bebb754ed v4.0.0 2026-07-11 03:28:19 +01:00
IRHM c8920c911d migrate: Change debug log to info
and add an info migration not been applied before log.
2026-07-11 03:27:31 +01:00
IRHM dd0bae137f migrations: Remove comment TODO
Should be okay with them there.
2026-07-10 22:08:04 +01:00
IRHM d5baee0df7 v4.0.0-dev1 2026-07-10 21:42:03 +01:00
IRHM 764a25be6c Changelog: Add youtube-nocookie change 2026-07-10 21:36:15 +01:00
IRHM c019162994 Update CHANGELOG.md 2026-07-10 20:30:23 +00:00
IRHM 05f9813be8 image: validate: Don't log whole image config 2026-07-10 20:30:23 +00:00
IRHM f285d1a8f4 image: Move new download/save methods to a Saver struct 2026-07-10 20:30:23 +00:00
IRHM 4cc9bd3edb make image module way more robust
- Create a better (more) re-usable flow for downloading images to disk and inserting images into the db
- Validate images properly by reading them fully and using DecodeConfig
- Fix some security issues by re-encoding images
- Always outputs one format `jpeg`, which keeps everything "normalized" and adds compression to images (mainly user uploads which might be big).
2026-07-10 20:30:23 +00:00
IRHM 34f7f54e72 fix: Add headers to requests for files in img/ folder to prevent stored xss attacks
A modern browser will see these headers and protect the user by not allowing any code to run.
2026-07-10 20:30:23 +00:00
hilmer 3fe2d2b155 Fix(ViewTrailerButton): Use youtube-nocookie.com 2026-07-05 17:49:12 +00:00
IRHM 3461a4ec74 db: Add duration to log for optimization queries 2026-07-05 17:39:42 +00:00
IRHM 475c9b6ab4 CHANGELOG: Add link for unreleased 2026-07-05 17:39:42 +00:00
IRHM 325666c30d CHANGELOG: Add credit to unreleased change 2026-07-05 17:39:42 +00:00
IRHM 92816b02a0 doc: Backup: Also note that watcharr.db-wal should be backed up along with the .db file since v3. 2026-07-05 17:39:42 +00:00
IRHM 363c648964 changelog/README: Fix the link to changelog... 2026-07-05 17:39:42 +00:00
IRHM 2f929e99d5 Move all old github release notes into new CHANGELOG
Having the changelog file is one more step to no vendor lock-in. It's also more easily searched through and in a standard format everyone is probably used to.

Just so you know, this was painful and I wanted nothing but death for myself throughout this whole process.
2026-07-05 17:39:42 +00:00
IRHM f14be89680 Update CHANGELOG.md 2026-07-05 17:39:42 +00:00
IRHM ecd027f603 Create CHANGELOG.md 2026-07-05 17:39:42 +00:00
IRHM f80f237143 plays support for frontend
soz did this so long ago
2026-07-05 17:39:42 +00:00
IRHM dbfc354353 GetWatchedPage: Put filter scope on one line 2026-07-05 17:39:42 +00:00
IRHM 28a6d85672 Update watched.go 2026-07-05 17:39:42 +00:00
IRHM b861cfcec3 separate watched_refine funcs 2026-07-05 17:39:42 +00:00
IRHM 08e0021535 refineSort: Filter by user_id AND use count_as_play instead of doing text search for LastFinished sort 2026-07-05 17:39:42 +00:00
IRHM b3bf111f82 Replace Fatal log with more direct Error and os.Exit 2026-07-05 17:39:42 +00:00
IRHM 23ed5ffca8 Add separator log for log files so a new server starting is easier to find/notice 2026-07-05 17:39:42 +00:00
IRHM cc9f246c9a update watched_util_test with new model name 2026-07-05 17:39:42 +00:00
IRHM 75d75248e1 Remove AddActivity endpoint
it isn't used and is only a way for others to potentially add activity to other peoples watched entries since user owning the watched entry is not validated on the endpoint.

Co-Authored-By: Dreddy <24421368+dredsen@users.noreply.github.com>
2026-07-05 17:39:42 +00:00
IRHM c34a0878b2 New Deref helper for using pointers safely with default values 2026-07-05 17:39:42 +00:00
IRHM 681212566b activity: Change AddRequest name to Props. Add CountAsPlay param to AddActivity()
and some formatting
2026-07-05 17:39:42 +00:00
IRHM df37de00e1 manually validate WatchedUpdateRequest struct and add plays count to watcheddto 2026-07-05 17:39:42 +00:00
IRHM fc900d8e93 db: Optimize when opening and new optimize task ran every 24hr server is running to keep it running best 2026-07-05 17:39:42 +00:00
IRHM 57ce143ebe migrate db to WAL journal_mode 2026-07-05 17:39:42 +00:00
IRHM 3eb2810a31 db: Configure sqlite connection when open. Currently set synchronous to FULL
(did this work a long time ago now so commits are in weird order)
2026-07-05 17:39:42 +00:00
IRHM b8598ba404 Remove deleted_at columns for watched episode/season tables
We don't use these columns and won't in the foreseeable future. Query speed seems be to be faster now that we aren't filtering for the extra deleted_at cols.
2026-07-05 17:39:42 +00:00
IRHM 579428d86b migrate: Backfill media plays data from existing activity 2026-07-05 17:39:42 +00:00
IRHM 4da8730b6f Activity: Add CountAsPlay property 2026-07-05 17:39:42 +00:00
IRHM e980a1f70e db: Create custom migrations support 2026-07-05 17:39:42 +00:00
IRHM f73105e9c7 v3.0.2-dev1 2026-03-19 01:30:26 +00:00
IRHM 755b08b746 Nav: Fix logo link being clickable through whole left side of nav
The logo link on nav to go to home page was clickable through the whole left side of the nav, now it is only clickable over the actual link text.
2026-03-19 01:29:16 +00:00
IRHM 5e412f67b9 Status: Fix button sizes now that icons have a width/height set 2026-03-19 01:29:16 +00:00
IRHM 4ee38a2625 Icon: Fix status icons not having width and height properties not set 2026-03-19 01:29:16 +00:00
IRHM dc6ead0fb3 fix safari: Shrikhand font
The font-style in the @font-face declaration was set to italic, but we use the font without using that style, which Safari is very strict about and wont match.
2026-03-19 01:29:16 +00:00
IRHM a12faf8e72 v3.0.1 2026-03-09 11:14:19 +00:00
IRHM eb1b8b86c7 v3.0.1-dev2 2026-03-09 10:48:51 +00:00
IRHM dca1acbbd8 Modal: Specify color
Fixes WatchedDeleteModal for light theme because of inherited css from content pages and this is the easiest fix for now
2026-03-09 10:47:24 +00:00
IRHM 664caa6ba2 v3.0.1-dev1 2026-03-09 09:14:26 +00:00
IRHM a425abcdf2 Poster: Grayscale poster if deleted from watched list through poster button
After deleting from your watched list through the poster, we didn't have any real indication to the user that this poster is for a deleted entry (other than the notification and removing the watched status from poster).

Now we grayscale and blur the poster slightly so it's more obvious that it is a deleted entry and won't be there next time you refresh.
2026-03-09 09:12:06 +00:00
IRHM 13b15e03d0 Poster: Add check for watched before calling removeWatched 2026-03-09 09:12:06 +00:00
IRHM 3b4a5a27e6 Add confirmation modal when deleting from your list
to avoid accidental data loss (thoughts, rating status)
2026-03-09 08:46:03 +00:00
IRHM 13bd37c32c doc: Fix old license being referenced and update some of the simple features list on the homepage. 2026-03-09 07:01:22 +00:00
IRHM 2d6d3c005c Activity: Fix automation icon not showing for mobile 2026-03-09 07:01:22 +00:00
IRHM f72f950871 Icon: Fix 'sparkles' svg path on dark theme by using currentColor fill for the path 2026-03-09 07:01:22 +00:00
IRHM e4e015daf8 age->GetAge: If we don't have a birthday skip processing
Otherwise eg: we get current year - 1 as someones age if their birthday isn't listed on tmdb
2026-03-09 07:01:22 +00:00
IRHM 3db89f3214 SpinnerTiny: Fix color for dark theme 2026-03-09 03:34:57 +00:00
IRHM d379d3f147 igdb: Fix some logs, only log req on Debug because it could be sensitive 2026-03-09 03:34:57 +00:00
IRHM ac8d8769af import: Support games importing
Fixing watcharr import from watcharr export not importing users games.

Also works if user edits the table in import/process to change the type of something to a game (and multi results works too).

Added warning message if user tries importing a watcharr export that has games when `!serverFeatures.games`
2026-03-09 03:34:57 +00:00
IRHM 7a7bd4f566 jellyfin sync: Fix addWatched params formatting 2026-03-06 18:33:12 +00:00
IRHM ca82a36d23 sync: Update err soft deleted comments 2026-03-06 18:33:12 +00:00
IRHM 1e6a97202b tag: GetTagPage: Remove debug call 2026-03-06 18:33:12 +00:00
IRHM 0e54a8c44a sync: Fix already on list handling by using new add watched func AND no longer auto restore soft deleted records
no longer auto restoring soft deleted records means if the user (soft) deletes a show (default behavior), the next sync job wont add it back.
2026-03-06 18:33:12 +00:00
IRHM 5c4c7be115 import: Fix already on list handling by using new add watched func 2026-03-06 18:33:12 +00:00
IRHM f458eec732 watched: abide by new api in router 2026-03-06 18:33:12 +00:00
IRHM 8ad2208162 watched: Restore can be skipped with flag and now returns a new error if watched just plain exists 2026-03-06 18:33:12 +00:00
235 changed files with 11648 additions and 6468 deletions
-15
View File
@@ -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
-27
View File
@@ -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,
},
};
+2
View File
@@ -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! -->
-27
View File
@@ -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"
+6 -6
View File
@@ -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
+7 -7
View File
@@ -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
+4 -4
View File
@@ -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:
+3 -3
View File
@@ -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
+13 -3
View File
@@ -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: |
+3 -3
View File
@@ -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
+2
View File
@@ -1 +1,3 @@
engine-strict=true
ignore-scripts=true
min-release-age=14
+1912
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+15 -2
View File
@@ -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/>.
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 878 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

+5
View File
@@ -0,0 +1,5 @@
## Changelog Data
Changelog images, etc go in this folder.
[View the real changelog for Watcharr.](../CHANGELOG.md)
+6 -1
View File
@@ -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>)]
+7 -5
View File
@@ -17,7 +17,7 @@ This section assumes you will be forking the repo on Github, of course you can a
![Fork button](./img/forking-repo.png)
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):
+1 -1
View File
@@ -35,7 +35,7 @@ Backing up this way is discouraged because new important files could be added la
If you don't care about the warning not to backup this way, here are the "important" files that you can single out for backup:
- `watcharr.db` Your database, holds all users, their watchlists, etc.
- `watcharr.db` & `watcharr.db-wal` Your database, holds all users, their watchlists, etc.
- `watcharr.json` Server config.
- `img/up` Profile picture uploads.
- `img/games` Game posters (not exactly important, but scenarios in which this folder is not backed up have not been tested, only relevant for servers with game support enabled).
+7 -8
View File
@@ -1,4 +1,3 @@
import React from "react";
import clsx from "clsx";
import styles from "./styles.module.css";
@@ -8,18 +7,18 @@ const FeatureList = [
Svg: require("@site/static/img/icon/code-slash.svg").default,
description: (
<>
Watcharr is free and open source software distributed under the MIT
license. Feel free to browse, modify or contribute!
Free and open source, distributed under the GPLv3 license. Feel free to
browse, modify or contribute!
</>
),
},
{
title: "Simple",
title: "Simple & Intuitive",
Svg: require("@site/static/img/icon/happy.svg").default,
description: (
<>
Watcharr is incredibly easy to dive into. Install without hassle and get
started with the seamless and intuitive user experience.
Incredibly easy to dive into. Install within the minute and get started
with the seamless experience.
</>
),
},
@@ -28,8 +27,8 @@ const FeatureList = [
Svg: require("@site/static/img/icon/go.svg").default,
description: (
<>
Don't know what else to add here, so now you know Watcharr is built with
Go and Svelte.
Don't know what else to add here, so now you know I'm built with Go and
Svelte.
</>
),
},
+2 -2
View File
@@ -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
+60
View File
@@ -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",
},
],
},
},
);
+3390 -3255
View File
File diff suppressed because it is too large Load Diff
+23 -23
View File
@@ -1,7 +1,7 @@
{
"name": "watcharr",
"license": "GPL-3.0-only",
"version": "3.0.0",
"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
View File
@@ -1 +1 @@
3.0.0
4.2.1
+6
View File
@@ -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
+151 -3
View File
@@ -1,26 +1,58 @@
package database
import (
"log/slog"
"path"
"time"
"github.com/sbondCo/Watcharr/config"
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/database/migrate"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// Create a new database connection.
// Also runs migrations, etc, before returning connection.
// Any error returned from this func should always make our app Exit (caller
// handled).
//
// NOTE: Our mock db used in tests mimics this function, so if this func is
// changed, you should look at the mock db to make it match if it makes
// sense, so our tests stay accurate to prod.
func New() (*gorm.DB, error) {
slog.Info("New: Opening new database connection")
// Open the database.
db, err := gorm.Open(
sqlite.Open(path.Join(config.DataPath, "watcharr.db")),
&gorm.Config{TranslateError: true},
)
if err != nil {
slog.Error("New: Opening database failed.")
return nil, err
}
// Setup the db (migrations, etc)
if err := Setup(db); err != nil {
slog.Error("New: Setting up connection failed!", "error", err)
return nil, err
}
return db, nil
}
// Setup configures our db connection and applies migrations.
//
// NOTE: This exists as a separate function so it can be reused by our testutil
// package that we want to have configured in the same way as the main db so
// that tests reflect real life.
func Setup(db *gorm.DB) error {
if err := configure(db); err != nil {
slog.Error("Setup: Configuring connection failed!", "error", err)
return err
}
// Perform auto migration.
err = db.AutoMigrate(
slog.Info("Setup: AutoMigrating")
err := db.AutoMigrate(
&migrate.MigrationRecord{},
&entity.User{},
&entity.UserServices{},
&entity.Content{},
@@ -36,7 +68,123 @@ func New() (*gorm.DB, error) {
&entity.Tag{},
)
if err != nil {
return nil, err
slog.Error("Setup: Auto migration failed.")
return err
}
return db, nil
slog.Info("Setup: AutoMigrated")
// Perform our manual migrations.
if err := migrate.Now(db); err != nil {
slog.Error("Setup: Manual migrations failed.", "error", err)
return err
}
// Optimize database.
if err := optimize(db); err != nil {
slog.Error("Setup: Optimizing database failed.", "error", err)
return err
}
return nil
}
// Configure our SQLite database connection.
// Some PRAGMAs need to be defined per-connection, so we do that here.
func configure(db *gorm.DB) error {
slog.Info("configure: Configuring connection.")
// Synchronous: https://sqlite.org/pragma.html#pragma_synchronous
// Configured to `FULL` because I'm slightly confused.
// `NORMAL` is recommended for most apps with WAL, but you
// lose "durability", which doesn't sound like a good thing to me...
// personally I'm okay with less performance for the best durability.
if res := db.Exec("PRAGMA synchronous=2"); res.Error != nil {
slog.Error("configure: Configuring synchronous failed!")
return res.Error
}
slog.Info("configure: Configured synchronous.")
return nil
}
// Optimize the database.
func optimize(db *gorm.DB) error {
slog.Info("optimize: Running optimizations.")
// WAL Checkpoint (aka commit anything in the WAL to the main db).
// Seems best to do this to make sure it has happened, especially since
// we are running vacuum next.
// https://sqlite.org/pragma.html#pragma_wal_checkpoint
timeBeforeQuery := time.Now()
if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil {
slog.Error("optimize: Checkpoint failed!")
return res.Error
}
slog.Info("optimize: Checkpointed.", "took", time.Since(timeBeforeQuery))
// Optimize pragma.
// Running with recommended argument for our new long-living connection.
// https://sqlite.org/pragma.html#pragma_optimize
timeBeforeQuery = time.Now()
if res := db.Exec("PRAGMA optimize=0x10002"); res.Error != nil {
slog.Error("optimize: Optimize pragma failed!")
return res.Error
}
slog.Info("optimize: Optimize pragma succeeded.",
"took", time.Since(timeBeforeQuery))
// Vacuum.
// > VACUUM rebuilds the database file, repacking it into a minimal amount
// > of disk space.
// https://sqlite.org/lang_vacuum.html
timeBeforeQuery = time.Now()
if res := db.Exec("VACUUM"); res.Error != nil {
slog.Error("optimize: Vacuum failed!")
return res.Error
}
slog.Info("optimize: Vacuumed successfully.",
"took", time.Since(timeBeforeQuery))
// WAL Checkpoint (aka commit anything in the WAL to the main db).
// Do this after vacuum too to ensure we have a clean slate for this
// startup.
// https://sqlite.org/pragma.html#pragma_wal_checkpoint
timeBeforeQuery = time.Now()
if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil {
slog.Error("optimize: Checkpoint failed!")
return res.Error
}
slog.Info("optimize: Checkpointed.", "took", time.Since(timeBeforeQuery))
slog.Info("optimize: Done.")
return nil
}
// Optimize task that is scheduled and ran every whenever.
// So this func is for optimzations that we want to re-run every time the
// task is scheduled for.
func TaskOptimize(db *gorm.DB) error {
slog.Info("TaskOptimize: Running optimizations.")
// WAL Checkpoint (aka commit anything in the WAL to the main db).
// To avoid our WAL file becoming huge, we checkpoint regularly.
// https://sqlite.org/pragma.html#pragma_wal_checkpoint
timeBeforeQuery := time.Now()
if res := db.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); res.Error != nil {
slog.Error("TaskOptimize: Checkpoint failed!")
return res.Error
}
slog.Info("TaskOptimize: Checkpointed.",
"took", time.Since(timeBeforeQuery))
// Optimize pragma.
// No args for our task as recommended.
// https://sqlite.org/pragma.html#pragma_optimize
timeBeforeQuery = time.Now()
if res := db.Exec("PRAGMA optimize"); res.Error != nil {
slog.Error("TaskOptimize: Optimize pragma failed!")
return res.Error
}
slog.Info("TaskOptimize: Optimize pragma succeeded.",
"took", time.Since(timeBeforeQuery))
slog.Info("TaskOptimize: Done.")
return nil
}
+8
View File
@@ -12,3 +12,11 @@ type GormModel struct {
UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
}
// Same as GormModel, but without the DeletedAt field,
// so use this for tables where we don't need soft deletion.
type GormModelNoDel struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
+15 -1
View File
@@ -46,7 +46,7 @@ type Activity struct {
// secured (users can only view their own activities).
UserID uint `json:"-" gorm:"not null"`
// ID of watched list item this activity is linked to.
WatchedID uint `json:"watchedId" gorm:"not null"`
WatchedID uint `json:"watchedId" gorm:"not null;index"`
// Type of activity.
Type ActivityType `json:"type" gorm:"not null"`
// Holds custom data (ex, if rating changed, this can
@@ -54,4 +54,18 @@ type Activity struct {
Data string `json:"data" gorm:"not null"`
// Custom date for the activity, that the user can define.
CustomDate *time.Time `json:"customDate,omitempty"`
// Count this Activity as a Play?
// Currently this was the best way I could see forward for implementing
// counting plays of media that doesn't involve inefficient querying of
// the actitivties table (or a whole new table, which would create extra
// complexities itself, ie, plays/activity showing different records).
// We write to this field when creating the activity to count is as a play
// or not (ie when we create STATUS CHANGE activities with status of
// FINISHED, imports, etc).
// We won't support the user (or the system) modifying this value after
// creation; if the user wants to delete a 'Play', they should delete the
// activity.
// Indexed (check migrations) to make search faster, since we frequently
// do it over the whole table for watched sorting at the moment.
CountAsPlay bool `json:"countAsPlay"`
}
+12
View File
@@ -12,6 +12,18 @@ const (
DROPPED WatchedStatus = "DROPPED"
)
func (r WatchedStatus) IsValid() bool {
switch r {
case FINISHED,
WATCHING,
PLANNED,
HOLD,
DROPPED:
return true
}
return false
}
type Watched struct {
dbmodel.GormModel
Status WatchedStatus `json:"status"`
+1 -1
View File
@@ -8,7 +8,7 @@ import "github.com/sbondCo/Watcharr/database/dbmodel"
// since episodes can be removed and re-added. For this reason we store season and episodes nums instead
// of just the episode id.
type WatchedEpisode struct {
dbmodel.GormModel
dbmodel.GormModelNoDel
UserID uint `json:"-" gorm:"not null"`
User User `json:"-"`
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
+1 -1
View File
@@ -4,7 +4,7 @@ import "github.com/sbondCo/Watcharr/database/dbmodel"
// UniqueIndex applied between WatchedID and SeasonNumber to avoid duplicates incase logic fails.
type WatchedSeason struct {
dbmodel.GormModel
dbmodel.GormModelNoDel
UserID uint `json:"-" gorm:"not null"`
User User `json:"-"`
WatchedID uint `json:"-" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
+11
View File
@@ -0,0 +1,11 @@
package migrate
import "time"
// Record of all our applied migrations.
type MigrationRecord struct {
// Migration ID
ID string `gorm:"primarykey"`
// When migration was applied on this db.
CreatedAt time.Time
}
+103
View File
@@ -0,0 +1,103 @@
package migrate
import (
"log/slog"
"time"
"gorm.io/gorm"
)
type Migration struct {
// ID of migration, stick to YYYYMMDDHHMM.
ID string
// Apply migration func.
Up func(tx *gorm.DB) error
// When `true`, the migration is not run inside of a transaction.
// You should only use this when is required by sqlite that the command
// we need to run for eg cannot be ran from within a transaction!
// YOU SHOULD ENSURE YOU ONLY RUN ONE COMMAND PER MIGRATION WHEN USING
// THIS WITH `TRUE` TO AVOID BEING LEFT IN A BAD OR INCOMPLETE STATE!!!
//
// ALSO: All statements used with this should take into account that since,
// it isn't inside of a transaction, it's possible the migration succeeds,
// but creating the record of it doesn't. If a user starts the server again
// after we error in this case, the migration will run again, so it must
// not break data integrity or make any assumptions of it being the first
// time running!
UNSAFE bool
}
// Start our migrations.
// NOTE: This is only to be ran after GORM's AutoMigrate.
func Now(db *gorm.DB) error {
slog.Info("Starting migrations.")
for _, mig := range migrations {
slog.Debug("Processing migration.", "id", mig.ID)
migRecord := MigrationRecord{ID: mig.ID}
// First ensure that the migration hasn't already been applied.
var alreadyApplied int64
res := db.
Model(&MigrationRecord{}).
Where(&migRecord).
Count(&alreadyApplied)
if res.Error != nil {
slog.Error("already applied check failed!")
return res.Error
}
if alreadyApplied > 0 {
// If record exists in our table, then migration was applied
// already, so skip processing it.
slog.Debug("Migration already applied.", "id", mig.ID)
continue
}
slog.Info("Migration has NOT been applied before.. applying.",
"id", mig.ID)
// Timing the migration.
timeBeforeMig := time.Now()
// Apply the migration.
if mig.UNSAFE {
// Unsafe migrations are not ran inside of a transaction
// and are only used when required by sqlite engine.
if err := mig.Up(db); err != nil {
slog.Error("Migration failed!", "id", mig.ID, "error", err)
return err
}
// Record the migration record.
if res := db.Create(&migRecord); res.Error != nil {
slog.Error("Unsafe migration succeeded, but we failed to create the record of it!",
"id", mig.ID, "error", res.Error)
return res.Error
}
} else {
// Migrations go through a transaction wrapper.
err := db.Transaction(func(tx *gorm.DB) error {
if err := mig.Up(tx); err != nil {
// Errored.. rollback any changes made.
return err
}
// Migration succeeded.. record it.
// If the Create succeeds, all will be committed.
return tx.Create(&migRecord).Error
})
if err != nil {
// If any migration fails, we return here.
slog.Error("Migration failed!", "id", mig.ID, "error", err)
return err
}
}
slog.Info("Migration applied successfully.",
"id", mig.ID,
"duration", time.Since(timeBeforeMig))
}
slog.Info("Done processing all migrations.")
return nil
}
+137
View File
@@ -0,0 +1,137 @@
package migrate
import (
"errors"
"log/slog"
"strings"
"github.com/sbondCo/Watcharr/database/entity"
"gorm.io/gorm"
)
// NOTE: For obvious reasons, once a migration is created and in production,
// it is set in stone, so there should be almost no reason to change an existing
// migration, create a new one instead!
// If it's not obvious, changing an existing migration won't apply for people
// who already have applied it and only apply for people who haven't yet,
// so we are risking splitting the consistency of everyones databases as a
// whole. I can't forsee any circumstance that would require doing so..
var migrations = []Migration{
{
// Backfilling `plays` data from users Activity.
// (we have just created the 'count_as_play' column, instead of starting
// existing data from 0 plays, we can check what existing activities
// should count, and count them).
ID: "202603201715_0001",
Up: func(tx *gorm.DB) error {
migID := "202603201715_0001"
slog.Info("Migration is starting.", "mig", migID)
// For ADDED_WATCHED and STATUS_CHANGED activities where the data
// holds something saying FINISHED somewhere, count as a play.
res := tx.
Model(&entity.Activity{}).
Where(
`type IN ? AND data LIKE "%FINISHED%"`,
[]entity.ActivityType{
entity.ADDED_WATCHED,
entity.STATUS_CHANGED,
},
).
Update("count_as_play", 1)
if res.Error != nil {
slog.Error("First step failed!", "mig", migID,
"error", res.Error)
return res.Error
}
slog.Info("First step succeeded, continuing.", "mig", migID)
// For IMPORTED_ADDED_WATCHED* activities, we don't need to check
// data (since there isn't any). We know these should always count
// as a play, so count them.
res = tx.
Model(&entity.Activity{}).
Where("type IN ?", []entity.ActivityType{
entity.IMPORTED_ADDED_WATCHED,
entity.IMPORTED_ADDED_WATCHED_JF,
entity.IMPORTED_ADDED_WATCHED_PLEX,
}).
Update("count_as_play", 1)
if res.Error != nil {
slog.Error("Second step failed!", "mig", migID,
"error", res.Error)
return res.Error
}
slog.Info("Second step succeeded, continuing.", "mig", migID)
return nil
},
},
{
// Dropping `deleted_at` columns for `watched_seasons` and
// `watched_episodes` tables since we do not use them.
ID: "202604142234_0002",
Up: func(tx *gorm.DB) error {
migID := "202604142234_0002"
slog.Info("Migration is starting.", "mig", migID)
// Drop deleted_at for watched_seasons
err := tx.Migrator().DropColumn(&entity.WatchedSeason{}, "deleted_at")
if err != nil {
slog.Error("watched_seasons migration failed!", "mig", migID)
return err
}
slog.Info("watched_seasons migration succeeded.", "mig", migID)
// Drop deleted_at for watched_episodes
err = tx.Migrator().DropColumn(&entity.WatchedEpisode{}, "deleted_at")
if err != nil {
slog.Error("watched_episodes migration failed!", "mig", migID)
return err
}
slog.Info("watched_episodes migration succeeded.", "mig", migID)
slog.Info("Migration complete.", "mig", migID)
return nil
},
},
{
// Moving to using WAL journal_mode for our sqlite database, which
// should grant us improvements in all areas.
// https://sqlite.org/pragma.html#pragma_journal_mode
ID: "202604162229_0003",
// NOTE: We are using `unsafe` so that this migration isn't ran inside
// of a transaction (can't change into WAL from within one), so we
// MUST ENSURE we are only doing one thing!
UNSAFE: true,
Up: func(db *gorm.DB) error {
migID := "202604162229_0003"
slog.Info("Migration is starting.", "mig", migID)
var mode string
res := db.Raw("PRAGMA journal_mode=WAL").Scan(&mode)
if res.Error != nil {
slog.Error("Setting journal_mode=WAL failed!", "mig", migID)
return res.Error
}
// Setting journal_mode might not return an error if it fails,
// it always returns the current journal_mode of the db, which
// will be WAL if it succeeds OR the "old" journal_mode if it
// wasn't changed.
// If the mode returned isn't WAL, then something has failed, so
// we'll error to stop here and prevent the migration record from
// being created, allowing the user to try again.
// Note: I was able to test this code by opening the db like this
// `sqlite.Open("file:data/watcharr.db?immutable=true")` and
// commenting out other code so we get right to this migration
// without failing at AutoMigration, etc.
slog.Info("journal_mode response.", "mode", mode)
if strings.ToLower(mode) != "wal" {
slog.Error("Setting journal_mode=WAL failed silently!")
return errors.New("Database is not in WAL mode after setting journal_mode=WAL")
}
slog.Info("WAL journal_mode migration succeeded.", "mig", migID)
slog.Info("Migration complete.", "mig", migID)
return nil
},
},
}
+19 -2
View File
@@ -7,7 +7,8 @@ import (
)
type (
ActivityAddRequest struct {
// Internal struct accepted by AddActivity function.
ActivityAddProps struct {
WatchedID uint `json:"watchedId" binding:"required"`
Type entity.ActivityType `json:"type" binding:"required"`
Data string `json:"data" binding:"required"`
@@ -19,6 +20,22 @@ type (
}
ActivityAddProvider interface {
AddActivity(userId uint, ar ActivityAddRequest) (entity.Activity, error)
AddActivity(
userId uint,
ar ActivityAddProps,
countAsPlay bool,
) (entity.Activity, error)
}
)
// Looks through Activity for Watched entry and calculates the amount
// that count as plays.
func getPlaysFromActivity(a []entity.Activity) int {
plays := 0
for i := range a {
if a[i].CountAsPlay {
plays++
}
}
return plays
}
+49 -3
View File
@@ -1,11 +1,35 @@
package domain
import (
"errors"
"time"
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/util"
)
type ImportContentType string
const (
ImportContentTypeMovie ImportContentType = "movie"
ImportContentTypeShow ImportContentType = "tv"
ImportContentTypeShowEpisode ImportContentType = "tv_episode"
ImportContentTypeGame ImportContentType = "game"
)
func ImportContentTypeToSearchType(t ImportContentType) SearchType {
switch t {
case ImportContentTypeMovie:
return SearchTypeMovie
case ImportContentTypeShow:
return SearchTypeShow
case ImportContentTypeGame:
return SearchTypeGame
}
// Empty string should be caught as an error.
return ""
}
type ImportResponseType string
var (
@@ -22,10 +46,13 @@ var (
)
type ImportRequest struct {
TmdbID int `json:"tmdbId"`
ImdbID string `json:"imdbId"`
IgdbID int `json:"igdbId"`
Name string `json:"name"`
Year int `json:"year"`
TmdbID int `json:"tmdbId"`
Type entity.ContentType `json:"type"`
Type ImportContentType `json:"type"`
Rating float64 `json:"rating" binding:"max=10"`
RatingCustomDate *time.Time `json:"ratingCustomDate"`
Status entity.WatchedStatus `json:"status"`
@@ -35,7 +62,26 @@ type ImportRequest struct {
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes"`
WatchedSeason []entity.WatchedSeason `json:"watchedSeasons"`
Tags []TagAddRequest `json:"tags"`
ImdbID string `json:"imdbId"`
}
// Internal struct given to the SuccessfulImport function.
type SuccessfulImportProps struct {
TmdbID int
IgdbID int
ContentType util.SupportedMedia
}
func NewSuccessfulImportPropsFromMedia(m *Media) (SuccessfulImportProps, error) {
p := SuccessfulImportProps{ContentType: m.GetMediaType()}
switch p.ContentType {
case util.SupportedMediaMovie, util.SupportedMediaShow:
p.TmdbID = m.IDs.TMDB
case util.SupportedMediaGame:
p.IgdbID = m.IDs.IGDB
default:
return p, errors.New("unsupported content type on media")
}
return p, nil
}
type ImportResponse struct {
+7
View File
@@ -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
@@ -98,6 +104,7 @@ func (t Media) GetId() int {
return -99
}
// If this changes, verify all use cases still make sense!
func (t Media) GetMediaType() util.SupportedMedia {
switch t.Type {
case MediaTypeTMDBMovie:
+52 -4
View File
@@ -1,6 +1,7 @@
package domain
import (
"errors"
"time"
"github.com/sbondCo/Watcharr/database/entity"
@@ -8,6 +9,13 @@ import (
"github.com/sbondCo/Watcharr/util"
)
var (
// Watched entry already exists.
ErrWatchedExists = errors.New("watched entry exists")
// Watched entry exists as a soft deleted record (..and restore wasn't attempted by choice).
ErrWatchedExistsSoftDeleted = errors.New("watched entry exists soft deleted")
)
type WatchedSort string
const (
@@ -45,6 +53,19 @@ type WatchedGetPageExtraProps struct {
Query string
}
type WatchedAddExtraProps struct {
// The type of activity this is (will be added to db as this type).
ActivityType entity.ActivityType
// When watched entry to db, if a unique constraint is hit, should
// we skip the restoration logic (bring back soft deleted record)?
// - Syncing logic may use this field as it might not make sense to restore
// a record when doing a task like a regular sync, otherwise the user could
// soft delte a record and then have it come back when they next sync.
// - Importers however probably shouldn't use this flag as true because
// importing will always be a manual decision.
DontRestore bool
}
type WatchedDto struct {
// Properties that always exist in every watched dto below.
@@ -68,6 +89,8 @@ type WatchedDto struct {
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes,omitempty"`
Tags []entity.Tag `json:"tags,omitempty"`
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
// Amount of plays this media has, calculated from activity.
Plays int `json:"plays,omitempty"`
}
// New dto with base properties that we have for all WatchedDtos.
@@ -116,6 +139,7 @@ func NewWatchedDtoForContentPage(w *entity.Watched) WatchedDto {
dto.WatchedEpisodes = w.WatchedEpisodes
dto.Tags = w.Tags
dto.LastViewedSeason = w.LastViewedSeason
dto.Plays = getPlaysFromActivity(w.Activity)
return dto
}
@@ -171,11 +195,35 @@ type WatchedAddRequest struct {
// Update watched entry request
type WatchedUpdateRequest struct {
Status entity.WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
Status entity.WatchedStatus `json:"status"`
Rating float64 `json:"rating"`
Thoughts string `json:"thoughts" `
RemoveThoughts bool `json:"removeThoughts"`
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
Pinned *bool `json:"pinned" `
// Allow the added activity count as play?
// If the activity was going to count, this can stop it.
LetCountAsPlay *bool `json:"letCountAsPlay"`
}
// If the struct is valid for the Update Request.
// - has atleast one property set.
// - If rating is set, can't be out of bounds.
func (w WatchedUpdateRequest) Valid() error {
if w.Status == "" &&
w.Rating == 0 &&
(w.Thoughts == "" && !w.RemoveThoughts) &&
w.Pinned == nil &&
w.LetCountAsPlay == nil {
// No properties are set, so this struct is not valid.
return errors.New("no properties provided")
}
if w.Status != "" && !w.Status.IsValid() {
return errors.New("status is not set to a supported status")
}
if w.Rating < 0 || w.Rating > 10 {
return errors.New("rating can only be a value from 0-10")
}
return nil
}
// Update response.
+45 -13
View File
@@ -19,31 +19,61 @@ func NewService(db *gorm.DB) *Service {
}
}
func (s *Service) GetActivity(userId uint, watchedId uint) ([]entity.Activity, error) {
func (s *Service) GetActivity(
userId uint,
watchedId uint,
) ([]entity.Activity, error) {
activity := new([]entity.Activity)
res := s.db.Model(&entity.Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
res := s.db.Model(&entity.Activity{}).
Where("user_id = ? AND watched_id = ?", userId, watchedId).
Find(&activity)
if res.Error != nil {
slog.Error("Failed getting activity from database", "error", res.Error.Error())
slog.Error("Failed getting activity from database",
"error", res.Error.Error())
return []entity.Activity{}, errors.New("failed getting activity")
}
return *activity, nil
}
func (s *Service) AddActivity(userId uint, ar domain.ActivityAddRequest) (entity.Activity, error) {
// NOTE: Currently this function doesn't verify if `userId` owns the referenced
// watched item at `ar.WatchedID`. If we ever need this function to work from an
// "AddActivity" endpoint on the API, we should create another func that has
// that validation, since this func is only for internal operations!
// AddActivity: Only for internal use.
func (s *Service) AddActivity(
userId uint,
ar domain.ActivityAddProps,
// If this activity counts as a play.
countAsPlay bool,
) (entity.Activity, error) {
if ar.WatchedID == 0 {
return entity.Activity{}, errors.New("watchedId must be set to add an activity")
return entity.Activity{},
errors.New("watchedId must be set to add an activity")
}
activity := entity.Activity{
UserID: userId,
WatchedID: ar.WatchedID,
Type: ar.Type,
Data: ar.Data,
CustomDate: ar.CustomDate,
CountAsPlay: countAsPlay,
}
activity := entity.Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data, CustomDate: ar.CustomDate}
res := s.db.Create(&activity)
if res.Error != nil {
slog.Error("Error adding activity to database", "error", res.Error.Error())
return entity.Activity{}, errors.New("failed adding new activity to database")
slog.Error("Error adding activity to database",
"error", res.Error.Error())
return entity.Activity{},
errors.New("failed adding new activity to database")
}
slog.Debug("Adding activity", "added_activity", activity)
return activity, nil
}
func (s *Service) UpdateActivity(userId uint, id uint, activityUpdateRequest domain.ActivityUpdateRequest) error {
func (s *Service) UpdateActivity(
userId uint,
id uint,
activityUpdateRequest domain.ActivityUpdateRequest,
) error {
if id == 0 {
return errors.New("id must be set to update an activity")
}
@@ -55,11 +85,12 @@ func (s *Service) UpdateActivity(userId uint, id uint, activityUpdateRequest dom
Where("user_id = ? AND id = ?", userId, id).
Update("custom_date", activityUpdateRequest.CustomDate)
if res.Error != nil {
slog.Error("Error updating activity in database", "error", res.Error.Error())
slog.Error("Error updating activity in database",
"error", res.Error.Error())
return errors.New("failed updating activity in database")
}
if res.RowsAffected < 1 {
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
slog.Error("No activities were updated.")
return errors.New("failed updating activity in database")
}
slog.Debug("Updating activity", "updated_activity", id)
@@ -72,11 +103,12 @@ func (s *Service) DeleteActivity(userId uint, id uint) error {
}
res := s.db.Where("user_id = ?", userId).Delete(&entity.Activity{}, id)
if res.Error != nil {
slog.Error("Error deleting activity in database", "error", res.Error.Error())
slog.Error("Error deleting activity in database",
"error", res.Error.Error())
return errors.New("failed deleting activity in database")
}
if res.RowsAffected < 1 {
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
slog.Error("No activities were deleted.")
return errors.New("failed deleting activity from database")
}
return nil
-17
View File
@@ -27,7 +27,6 @@ func (r *Router) AddRoutes() {
activity := r.br.Router.Group("/activity").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
activity.GET(":watchedId", r.GetActivity)
activity.POST("", r.AddActivity)
activity.PUT(":id", r.UpdateActivity)
activity.DELETE(":id", r.DeleteActivity)
}
@@ -47,22 +46,6 @@ func (r *Router) GetActivity(c *gin.Context) {
c.JSON(http.StatusOK, activity)
}
func (r *Router) AddActivity(c *gin.Context) {
userId := c.MustGet("userId").(uint)
var ar domain.ActivityAddRequest
err := c.ShouldBindJSON(&ar)
if err == nil {
response, err := r.service.AddActivity(userId, ar)
if err != nil {
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
}
func (r *Router) UpdateActivity(c *gin.Context) {
userId := c.MustGet("userId").(uint)
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
@@ -14,6 +14,10 @@ import (
// Auth middleware
// If db is passed, extra user info from the database will be fetched.
//
// **NOTE:** Instead of providing the `db` parameter, it is probably better to
// fetch what you need in the handler directly! We might follow that pattern
// from now on and potentially remove `db` from this func in the future.
func AuthRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
return func(c *gin.Context) {
slog.Debug("AuthRequired middleware hit")
+57 -406
View File
@@ -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
}
+31 -23
View File
@@ -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
+12 -19
View File
@@ -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 {
+13 -1
View File
@@ -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 {
+44
View File
@@ -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"))
}
+144 -177
View File
@@ -5,7 +5,6 @@ import (
"errors"
"log/slog"
"strconv"
"strings"
"time"
"github.com/sbondCo/Watcharr/database/entity"
@@ -19,7 +18,7 @@ import (
)
type WatchedProvider interface {
AddWatched(userId uint, ar domain.WatchedAddRequest, at entity.ActivityType) (entity.Watched, error)
AddWatched(userId uint, ar domain.WatchedAddRequest, extraProps domain.WatchedAddExtraProps) (entity.Watched, error)
GetWatchedItemByTmdbId(userId uint, tmdbId uint, contentType entity.ContentType) (entity.Watched, error)
}
@@ -31,26 +30,24 @@ type WatchedEpisodeProvider interface {
AddWatchedEpisodes(userId uint, ar episode.WatchedEpisodeAddRequest) (episode.WatchedEpisodeAddResponse, error)
}
type ContentProvider interface {
SearchContent(query string, pageNum int) (tmdb.TMDBSearchMultiResponse, 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 TagProvider interface {
AddTag(userId uint, tr domain.TagAddRequest) (entity.Tag, error)
GetTagByNameAndColor(userId uint, tagName string, tagColor string, tagBgColor string) (entity.Tag, error)
}
type SearchProvider interface {
Search(r domain.SearchRequest, pp util.PaginationParams, userId uint) (domain.SearchResponse, error)
}
type Service struct {
db *gorm.DB
wp WatchedProvider
wsp WatchedSeasonProvider
wep WatchedEpisodeProvider
cp ContentProvider
tmdb *tmdb.TMDB
activityProvider domain.ActivityAddProvider
tagProvider TagProvider
searchProvider SearchProvider
}
func NewService(
@@ -58,178 +55,77 @@ func NewService(
wp WatchedProvider,
wsp WatchedSeasonProvider,
wep WatchedEpisodeProvider,
cp ContentProvider,
tmdb *tmdb.TMDB,
activityProvider domain.ActivityAddProvider,
tagProvider TagProvider,
searchProvider SearchProvider,
) *Service {
return &Service{
db,
wp,
wsp,
wep,
cp,
tmdb,
activityProvider,
tagProvider,
searchProvider,
}
}
// TODO Support game importing
func (s *Service) ImportContent(
userId uint,
ar domain.ImportRequest,
) (domain.ImportResponse, error) {
slog.Debug("import: Processing request:", "request", ar)
// If tmdbId and type passed in request body
// we dont need to use a search tmdb request.
// Retrieve the details directly.
if ar.TmdbID != 0 && (ar.Type == entity.MOVIE || ar.Type == entity.SHOW) {
tid := strconv.Itoa(ar.TmdbID)
if ar.Type == entity.MOVIE {
cr, err := s.cp.MovieDetails(tid, "", map[string]string{})
if err != nil {
return domain.ImportResponse{}, errors.New("movie details request failed")
}
slog.Debug("import: by tmdbid of movie", "cr", cr)
return s.SuccessfulImport(userId, cr.ID, util.SupportedMediaMovie, ar)
} else if ar.Type == entity.SHOW {
cr, err := s.cp.TvDetails(tid, "", map[string]string{})
if err != nil {
return domain.ImportResponse{}, errors.New("tv details request failed")
}
slog.Debug("import: by tmdbid of tv", "cr", cr)
return s.SuccessfulImport(userId, cr.ID, util.SupportedMediaShow, ar)
// If we have a TMDB ID given to us, we can go directly to
// `SuccessfulImport` and let AddWatched fail if it doesn't exist.
if ar.TmdbID != 0 {
switch ar.Type {
case domain.ImportContentTypeMovie:
return s.SuccessfulImport(userId, &ar, domain.SuccessfulImportProps{
TmdbID: ar.TmdbID,
ContentType: util.SupportedMediaMovie,
}), nil
case domain.ImportContentTypeShow:
return s.SuccessfulImport(userId, &ar, domain.SuccessfulImportProps{
TmdbID: ar.TmdbID,
ContentType: util.SupportedMediaShow,
}), nil
}
// Unsupported types will continue to below...
}
// If imdb id passed, attempt to get content with it
if ar.ImdbID != "" && (ar.Type == entity.MOVIE || ar.Type == entity.SHOW || ar.Type == entity.SHOW_EPISODE) {
if imdbResp, err := s.cp.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) {
// Will only be one result
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
return s.SuccessfulImport(userId, onlyResult.ID, util.SupportedMedia(onlyResult.MediaType), ar)
} else if onlyResult.MediaType == string(entity.SHOW_EPISODE) {
// Handle episodes differently.
// Clients must import tv episodes last so that the actual show can be imported first
// will fail if watched entry isn't imported first or already exists (we won't make it here).
w, e := s.wp.GetWatchedItemByTmdbId(userId, uint(onlyResult.ShowId), "tv")
if e != nil {
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
ws, err := s.wep.AddWatchedEpisodes(userId, episode.WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: onlyResult.SeasonNumber,
EpisodeNumber: onlyResult.EpisodeNumber,
Status: ar.Status,
Rating: int8(ar.Rating),
AddActivityDate: *ar.RatingCustomDate,
})
if err != nil {
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
} else {
w.WatchedEpisodes = ws.WatchedEpisodes
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}, nil
}
} else {
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
} else {
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
}
} else {
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
if ar.ImdbID != "" && (ar.Type == domain.ImportContentTypeMovie ||
ar.Type == domain.ImportContentTypeShow ||
ar.Type == domain.ImportContentTypeShowEpisode) {
// Try import with imdb id.
resp, err := s.importWithIMDBID(userId, &ar)
if err == nil || !errors.Is(err, ErrNoResult) {
return resp, err
}
// If ErrNoResult then we allow going below to search by name.
}
// tmdbId not passed.. search for the content by name.
sr, err := s.cp.SearchContent(ar.Name, 1)
if err != nil {
slog.Error("import: content search failed", "error", err)
return domain.ImportResponse{}, errors.New("content search failed")
}
// potential matches
pMatches := []domain.Media{}
for _, r := range sr.Results {
if r.MediaType != "person" {
pMatches = append(pMatches, r.AsMedia())
}
}
resLen := len(pMatches)
slog.Debug("import: potential matches", "num_found", resLen)
if resLen <= 0 {
slog.Debug("import: returning IMPORT_NOTFOUND")
return domain.ImportResponse{Type: domain.IMPORT_NOTFOUND}, nil
} else if resLen > 1 {
slog.Debug("import: multiple results found")
// If there are multiple responses, but only one item
// from the results is a 100% match for the imported
// items name, then consider successful match with that.
perfectMatches := []domain.Media{}
for _, r := range pMatches {
itemReleaseYear := 0
// Only parse dates to find year if the import request has provided
// a year to comparisons.. otherwise don't do it to save some performance juice.
if ar.Year != 0 {
if !r.ReleaseDate.IsZero() {
itemReleaseYear = r.ReleaseDate.Year()
} else {
slog.Error("import: failed to check item release year, it can't be used for matching",
"error", err, "item", r)
}
}
if strings.EqualFold(r.Name, ar.Name) {
slog.Debug("import: multiple results processing: found a perfect name match", "itemReleaseYear", itemReleaseYear, "ar.Year", ar.Year, "match", r)
// If we have a year for comparison, force a check to compare them for a
// match to be deemed perfect.
// `itemReleaseYear` can only ever have a value if `ar.Year` has one, so this
// check is safe as is.
if itemReleaseYear != 0 || ar.Year != 0 {
if itemReleaseYear == ar.Year {
perfectMatches = append(perfectMatches, r)
slog.Debug("import: multiple results processing: name match also matched year")
} else {
slog.Debug("import: multiple results processing: name match didnt match year")
}
continue
}
// Otherwise, if we don't have valid dates to compare, append the perfect name match anyways.
slog.Debug("import: multiple results processing: name match didn't have valid release year, adding to matches anyways")
perfectMatches = append(perfectMatches, r)
}
}
// If one perfect match found, import it
pmLen := len(perfectMatches)
if pmLen == 1 && perfectMatches[0].IDs.TMDB != 0 {
slog.Debug("import: importing from perfect match")
return s.SuccessfulImport(
userId,
perfectMatches[0].IDs.TMDB,
perfectMatches[0].GetMediaType(),
ar)
}
slog.Debug("import: returning all potential matches")
return domain.ImportResponse{Type: domain.IMPORT_MULTI, Results: pMatches}, nil
} else {
slog.Debug("import: success.. only found one result")
return s.SuccessfulImport(
userId,
pMatches[0].IDs.TMDB,
pMatches[0].GetMediaType(),
ar)
// If igdb provided, go straight to SuccessfulImport with it.
if ar.IgdbID != 0 && (ar.Type == domain.ImportContentTypeGame) {
return s.SuccessfulImport(userId, &ar, domain.SuccessfulImportProps{
IgdbID: ar.IgdbID,
ContentType: util.SupportedMediaGame,
}), nil
}
// If we have no IDs, run importWithName, which searches for content
// by name.
return s.importWithName(userId, &ar)
}
func (s *Service) SuccessfulImport(
userId uint,
contentId int,
contentType util.SupportedMedia,
ar domain.ImportRequest,
) (domain.ImportResponse, error) {
ar *domain.ImportRequest,
props domain.SuccessfulImportProps,
) domain.ImportResponse {
status := entity.FINISHED
if ar.Status != "" {
status = ar.Status
@@ -243,46 +139,102 @@ func (s *Service) SuccessfulImport(
}
}
}
w, err := s.wp.AddWatched(userId, domain.WatchedAddRequest{
// Build WatchedAddRequest
wAddReq := domain.WatchedAddRequest{
ContentType: props.ContentType,
Status: status,
TMDBID: contentId,
ContentType: contentType,
Rating: ar.Rating,
Thoughts: ar.Thoughts,
WatchedDate: wDate,
}, entity.IMPORTED_WATCHED)
}
switch props.ContentType {
case util.SupportedMediaMovie, util.SupportedMediaShow:
wAddReq.TMDBID = props.TmdbID
case util.SupportedMediaGame:
wAddReq.IGDBID = props.IgdbID
default:
slog.Error("successfulImport: Invalid contentType provided!",
"content_type", props.ContentType)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}
}
// Running add watched
w, err := s.wp.AddWatched(
userId,
wAddReq,
domain.WatchedAddExtraProps{
ActivityType: entity.IMPORTED_WATCHED,
})
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("successfulImport: unique constraint hit.. show must already be on watch list", "error", err)
return domain.ImportResponse{Type: domain.IMPORT_EXISTS}, nil
if errors.Is(err, domain.ErrWatchedExists) {
slog.Error("successfulImport: Must already be on watch list",
"error", err)
return domain.ImportResponse{Type: domain.IMPORT_EXISTS}
}
slog.Error("successfulImport: Failed to add content as watched", "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
slog.Error("successfulImport: Failed to add content as watched",
"error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}
}
// Add activity of the original time the show was added to the users
// watchlist on whichever platform they are coming from.
if ar.RatingCustomDate != nil {
var addedActivity entity.Activity
if len(w.Activity) > 0 {
activityJson, _ := json.Marshal(map[string]interface{}{
activityJson, _ := json.Marshal(map[string]any{
"rating": ar.Rating,
"linkedActivity": w.Activity[0].ID,
})
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_RATING,
Data: string(activityJson),
CustomDate: ar.RatingCustomDate,
},
false,
)
} else {
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_RATING,
Data: strconv.Itoa(int(ar.Rating)),
CustomDate: ar.RatingCustomDate,
},
false,
)
}
w.Activity = append(w.Activity, addedActivity)
}
// Add all dates watched as activity, if any
if len(ar.DatesWatched) > 0 {
for _, v := range ar.DatesWatched {
for i, v := range ar.DatesWatched {
countAsPlay := true
if i == 0 && ar.Status == entity.FINISHED {
// If the watched status we are importing is of FINISHED
// then the first DatesWatched must not count as a play,
// since the import activity (set in AddWatched) will already.
// Any subsequent DatesWatched should count as a play though.
countAsPlay = false
slog.Info("successfulImport: Set countAsPlay=false for first" +
"DatesWatched to avoid duplicate play count with AddWatched activity.")
}
customDate := v
addedActivity, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
addedActivity, err := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED,
CustomDate: &customDate,
},
countAsPlay,
)
if err == nil {
w.Activity = append(w.Activity, addedActivity)
} else {
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
slog.Error("successfulImport: Failed to add dateswatched activity.",
"date", v, "error", err)
}
}
}
@@ -295,11 +247,21 @@ func (s *Service) SuccessfulImport(
if activityDate == nil || activityDate.IsZero() {
activityDate = &ar.Activity[i].CreatedAt
}
addedActivity, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
addedActivity, err := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: v.Type,
Data: v.Data,
CustomDate: activityDate,
},
v.CountAsPlay,
)
if err == nil {
w.Activity = append(w.Activity, addedActivity)
} else {
slog.Error("successfulImport: Failed to add imported activity.", "full_object", v, "error", err)
slog.Error("successfulImport: Failed to add imported activity.",
"full_object", v, "error", err)
}
}
}
@@ -315,7 +277,8 @@ func (s *Service) SuccessfulImport(
AddActivityDate: v.CreatedAt,
})
if err != nil {
slog.Error("successfulImport: Failed to add watched season.", "error", err)
slog.Error("successfulImport: Failed to add watched season.",
"error", err)
continue
}
w.WatchedSeasons = ws.WatchedSeasons
@@ -334,7 +297,8 @@ func (s *Service) SuccessfulImport(
AddActivityDate: v.CreatedAt,
})
if err != nil {
slog.Error("successfulImport: Failed to add watched episodes.", "error", err)
slog.Error("successfulImport: Failed to add watched episodes.",
"error", err)
continue
}
w.WatchedEpisodes = ws.WatchedEpisodes
@@ -349,7 +313,8 @@ func (s *Service) SuccessfulImport(
var t entity.Tag
t, err := s.tagProvider.GetTagByNameAndColor(userId, v.Name, v.Color, v.BgColor)
if err != nil && err.Error() != "tag does not exist" {
slog.Error("successfulImport: Failed to check for an existing tag", "name", v.Name, "error", err)
slog.Error("successfulImport: Failed to check for an existing tag",
"name", v.Name, "error", err)
continue
}
if t.ID == 0 {
@@ -359,7 +324,8 @@ func (s *Service) SuccessfulImport(
BgColor: v.BgColor,
})
if err != nil {
slog.Error("successfulImport: Failed to add a tag.", "name", v.Name, "error", err)
slog.Error("successfulImport: Failed to add a tag.",
"name", v.Name, "error", err)
continue
}
t = tag
@@ -368,11 +334,12 @@ func (s *Service) SuccessfulImport(
// Associate the watched entry with the tag
err = watched.AddWatchedToTag(s.db, userId, t.ID, w.ID)
if err != nil {
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
slog.Error("successfulImport: Failed to associate watched entry with tag.",
"error", err)
continue
}
w.Tags = append(w.Tags, t)
}
}
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}, nil
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}
}
+26 -22
View File
@@ -165,11 +165,12 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
}
rProc := func(v TraktHistory) {
var collectingText string
if v.Type == "episode" {
switch v.Type {
case "episode":
collectingText = fmt.Sprintf("%s S%dE%d", v.Show.Title, v.Episode.Season, v.Episode.Number)
} else if v.Type == "show" {
case "show":
collectingText = v.Show.Title
} else if v.Type == "movie" {
case "movie":
collectingText = v.Movie.Title
}
if collectingText != "" {
@@ -220,20 +221,21 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
var (
title string
contentType entity.ContentType
contentType domain.ImportContentType
tmdbId int
)
if v.Type == "show" || v.Type == "episode" {
switch v.Type {
case "show", "episode":
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
contentType = entity.SHOW
contentType = domain.ImportContentTypeShow
if v.Type == "episode" {
title = v.Episode.Title
}
} else if v.Type == "movie" {
case "movie":
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = entity.MOVIE
contentType = domain.ImportContentTypeMovie
}
job.UpdateJobCurrentTask(jobId, userId, "setting status for "+title)
mapKey := t.makeTraktMapKey(contentType, tmdbId)
@@ -255,7 +257,7 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: entity.PLANNED,
GormModel: dbmodel.GormModel{
GormModelNoDel: dbmodel.GormModelNoDel{
CreatedAt: v.ListedAt,
},
})
@@ -281,7 +283,7 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
SeasonNumber: v.Episode.Season,
EpisodeNumber: v.Episode.Number,
Status: entity.PLANNED,
GormModel: dbmodel.GormModel{
GormModelNoDel: dbmodel.GormModelNoDel{
CreatedAt: v.ListedAt,
},
}}
@@ -310,23 +312,24 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
slog.Debug("startTraktImport: Processing rating item", "item", v)
var (
title string
contentType entity.ContentType
contentType domain.ImportContentType
tmdbId int
traktSlug string
)
if v.Type == "show" || v.Type == "episode" {
switch v.Type {
case "show", "episode":
title = v.Show.Title
tmdbId = v.Show.Ids.Tmdb
traktSlug = v.Show.Ids.Slug
contentType = entity.SHOW
contentType = domain.ImportContentTypeShow
if v.Type == "episode" {
title = v.Episode.Title
traktSlug = v.Episode.Ids.Slug
}
} else if v.Type == "movie" {
case "movie":
title = v.Movie.Title
tmdbId = v.Movie.Ids.Tmdb
contentType = entity.MOVIE
contentType = domain.ImportContentTypeMovie
traktSlug = v.Movie.Ids.Slug
}
job.UpdateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
@@ -374,14 +377,15 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
title string
traktId int
tmdbId int
contentType entity.ContentType
contentType domain.ImportContentType
watchedEpisode entity.WatchedEpisode
)
if v.Type == "show" || v.Type == "episode" {
switch v.Type {
case "show", "episode":
title = v.Show.Title
traktId = v.Show.Ids.Trakt
tmdbId = v.Show.Ids.Tmdb
contentType = entity.SHOW
contentType = domain.ImportContentTypeShow
if v.Type == "episode" {
traktId = v.Episode.Ids.Trakt
watchedEpisode = entity.WatchedEpisode{
@@ -389,7 +393,7 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
EpisodeNumber: v.Episode.Number,
Status: entity.FINISHED,
// Rating: ,
GormModel: dbmodel.GormModel{
GormModelNoDel: dbmodel.GormModelNoDel{
CreatedAt: v.WatchedAt,
},
}
@@ -397,11 +401,11 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
} else {
slog.Debug("processTraktHistoryItem: Processing a show.", "contentTitle", title, "contentTmdbId", tmdbId)
}
} else if v.Type == "movie" {
case "movie":
title = v.Movie.Title
traktId = v.Movie.Ids.Trakt
tmdbId = v.Movie.Ids.Tmdb
contentType = entity.MOVIE
contentType = domain.ImportContentTypeMovie
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
}
if tmdbId == 0 {
@@ -425,7 +429,7 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
}
// `tmdbId` is for the movie or show (not for episodes).
func (t *TraktService) makeTraktMapKey(ct entity.ContentType, tmdbId int) string {
func (t *TraktService) makeTraktMapKey(ct domain.ImportContentType, tmdbId int) string {
return string(ct) + strconv.Itoa(tmdbId)
}
+204
View File
@@ -0,0 +1,204 @@
package imprt
import (
"errors"
"log/slog"
"strings"
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/domain"
"github.com/sbondCo/Watcharr/feature/watched/episode"
"github.com/sbondCo/Watcharr/util"
)
// Same Service as import.go but here so that one file doesn't get too hugemongus.
var (
ErrNoResult = errors.New("no result from specific data request")
)
// Import by name (and or type). We do a content search to find a result,
// then try to import. Multiple results can end in returning them all for the
// user to decide.
func (s *Service) importWithName(
userId uint,
ar *domain.ImportRequest,
) (domain.ImportResponse, error) {
// Try to search for results by name (and or type).
searchReq := domain.SearchRequest{
Query: ar.Name,
// Default to multi search.
Type: domain.SearchTypeMulti,
}
if ar.Type != "" {
searchType := domain.ImportContentTypeToSearchType(ar.Type)
if searchType == "" {
// Invalid.. `ar.Type` is unsupported.
slog.Error("importWithName: Invalid ImportContentType provided!",
"type", ar.Type)
return domain.ImportResponse{},
errors.New("invalid import content type provided")
}
searchReq.Type = searchType
}
searchResp, err := s.searchProvider.Search(
searchReq,
util.PaginationParams{Page: 1},
userId,
)
if err != nil {
slog.Error("importWithName: Search failed", "error", err)
return domain.ImportResponse{}, errors.New("search failed")
}
slog.Debug("importWithName: Potential matches",
"num_found", searchResp.TotalResults)
// If no results at all, return IMPORT_NOTFOUND
if searchResp.TotalResults <= 0 {
// No results found...
slog.Debug("importWithName: returning IMPORT_NOTFOUND")
return domain.ImportResponse{Type: domain.IMPORT_NOTFOUND}, nil
}
// If we did a multi search, remove people
results := []domain.Media{}
if searchReq.Type == domain.SearchTypeMulti {
for _, v := range searchResp.Results {
if v.Type != domain.MediaTypeTMDBPerson {
results = append(results, v)
}
}
} else {
results = searchResp.Results
}
// Process results
if len(results) > 1 {
slog.Debug("importWithName: Multiple results found")
return s.importWithNameHandleMultipleResultsFound(userId, ar, results)
} else {
slog.Debug("importWithName: success.. only found one result")
props, err := domain.NewSuccessfulImportPropsFromMedia(&results[0])
if err != nil {
slog.Error("importWithName: Couldn't create props!", "error", err)
return domain.ImportResponse{}, err
}
return s.SuccessfulImport(userId, ar, props), nil
}
}
// If the importWithName func ends up with multiple results found in the
// search response, this will handle the case by trying to find a perfectMatch,
// otherwise returning an IMPORT_MULTI response.
func (s *Service) importWithNameHandleMultipleResultsFound(
userId uint,
ar *domain.ImportRequest,
results []domain.Media,
) (domain.ImportResponse, error) {
perfectMatches := []domain.Media{}
for _, r := range results {
itemReleaseYear := 0
// Only parse dates to find year if the import request has provided
// a year (and to keep below matching logic working properly).
if ar.Year != 0 {
if !r.ReleaseDate.IsZero() {
itemReleaseYear = r.ReleaseDate.Year()
} else {
slog.Error("importWithName: Item has no ReleaseDate to use in comparison.")
}
}
if strings.EqualFold(r.Name, ar.Name) {
slog.Debug("importWithName: Found a perfect name match",
"itemReleaseYear", itemReleaseYear,
"ar.Year", ar.Year,
"match", r)
// If we have a year for comparison, force a check to compare them for a
// match to be deemed perfect.
// `itemReleaseYear` can only ever have a value if `ar.Year` has one, so this
// check is safe as is.
if itemReleaseYear != 0 || ar.Year != 0 {
if itemReleaseYear == ar.Year {
perfectMatches = append(perfectMatches, r)
slog.Debug("importWithName: Name match also matched year")
} else {
slog.Debug("importWithName: Name match didn't match year")
}
continue
}
// Otherwise, if we don't have valid dates to compare, append the perfect name match anyways.
slog.Debug("importWithName: Name match didn't have release years to compare, adding to matches anyways")
perfectMatches = append(perfectMatches, r)
}
}
// If one perfect match found, import it
if len(perfectMatches) == 1 && perfectMatches[0].IDs.TMDB != 0 {
slog.Debug("importWithName: importing from perfect match")
props, err := domain.NewSuccessfulImportPropsFromMedia(&perfectMatches[0])
if err != nil {
slog.Error("importWithName: Couldn't create props!", "error", err)
return domain.ImportResponse{}, err
}
return s.SuccessfulImport(userId, ar, props), nil
}
slog.Debug("importWithName: returning all potential matches")
return domain.ImportResponse{Type: domain.IMPORT_MULTI, Results: results}, nil
}
// Import with an IMDb ID.
func (s *Service) importWithIMDBID(
userId uint,
ar *domain.ImportRequest,
) (domain.ImportResponse, error) {
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) {
// Will only be one result
slog.Debug("import: importing imdb match", "imdb_id", ar.ImdbID, "tmdb_id_thatwasfound", onlyResult.ID)
return s.SuccessfulImport(
userId,
ar,
domain.SuccessfulImportProps{
TmdbID: onlyResult.ID,
ContentType: util.SupportedMedia(onlyResult.MediaType),
}), nil
} else if onlyResult.MediaType == string(entity.SHOW_EPISODE) {
// Handle episodes differently.
// Clients must import tv episodes last so that the actual show can be imported first
// will fail if watched entry isn't imported first or already exists (we won't make it here).
w, e := s.wp.GetWatchedItemByTmdbId(userId, uint(onlyResult.ShowId), "tv")
if e != nil {
slog.Error("import: imdb match: Failed to add watched episode (failed to find watched item, it must exist!).", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
ws, err := s.wep.AddWatchedEpisodes(userId, episode.WatchedEpisodeAddRequest{
WatchedID: w.ID,
SeasonNumber: onlyResult.SeasonNumber,
EpisodeNumber: onlyResult.EpisodeNumber,
Status: ar.Status,
Rating: int8(ar.Rating),
AddActivityDate: *ar.RatingCustomDate,
})
if err != nil {
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
} else {
w.WatchedEpisodes = ws.WatchedEpisodes
return domain.ImportResponse{Type: domain.IMPORT_SUCCESS, WatchedEntry: w}, nil
}
} else {
slog.Error("import: imdb match has unsupported media type.", "media_type", imdbResp.Results[0].MediaType, "rq", ar)
return domain.ImportResponse{Type: domain.IMPORT_FAILED}, nil
}
} else {
// Content in tmdb may just be missing a related imdb id, so allow search to continue by name below.
slog.Warn("import: No results for search by imdb id.. search will contiue by content name.", "rq", ar)
}
} else {
slog.Warn("import: Failed to get content by imdb id.. search will contiue by content name.", "rq", ar)
}
// ErrNoResult should be caught from caller and let fall through to search by name.
return domain.ImportResponse{}, ErrNoResult
}
+1 -1
View File
@@ -38,7 +38,7 @@ func (r *Router) ImportContent(c *gin.Context) {
if err == nil {
response, err := r.service.ImportContent(userId, ar)
if err != nil {
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
+98 -25
View File
@@ -41,7 +41,7 @@ type JellyfinSyncResponse struct {
}
type WatchedProvider interface {
AddWatched(userId uint, ar domain.WatchedAddRequest, at entity.ActivityType) (entity.Watched, error)
AddWatched(userId uint, ar domain.WatchedAddRequest, extraProps domain.WatchedAddExtraProps) (entity.Watched, error)
}
type WatchedSeasonProvider interface {
@@ -132,23 +132,58 @@ func (s *SyncService) startJellyfinSync(
job.UpdateJobCurrentTask(jobId, userId, "syncing "+v.Name)
// 2. Imported watched movie
w, err := s.wp.AddWatched(userId, domain.WatchedAddRequest{
Status: entity.FINISHED,
ContentType: util.SupportedMediaMovie,
TMDBID: tmdbId,
WatchedDate: v.UserData.LastPlayedDate,
}, entity.IMPORTED_WATCHED_JF)
w, err := s.wp.AddWatched(
userId,
domain.WatchedAddRequest{
Status: entity.FINISHED,
ContentType: util.SupportedMediaMovie,
TMDBID: tmdbId,
WatchedDate: v.UserData.LastPlayedDate,
},
domain.WatchedAddExtraProps{
ActivityType: entity.IMPORTED_WATCHED_JF,
DontRestore: true,
})
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
if errors.Is(err, domain.ErrWatchedExists) {
slog.Info("jellyfinSyncWatched: Content already exists on list.",
"movie_name", v.Name,
"movie_ids", v.ProviderIds,
"user_id", userId)
} else if errors.Is(err, domain.ErrWatchedExistsSoftDeleted) {
// Entry was manually soft deleted by user at another point,
// we DontRestore these automatically.
slog.Warn("jellyfinSyncWatched: Movie exists on list soft deleted.")
job.AddJobError(
jobId,
userId,
"failed to add movie "+
v.Name+
". You have previously deleted it from your list!")
continue
} else {
slog.Error("jellyfinSyncWatched: Movie failed to import.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
job.AddJobError(jobId, userId, "movie could not be imported (failed when adding to watched list): "+v.Name)
slog.Error("jellyfinSyncWatched: Movie failed to import.",
"movie_name", v.Name,
"movie_ids", v.ProviderIds,
"user_id", userId,
"error", err)
job.AddJobError(
jobId,
userId,
"movie could not be imported (failed when adding to watched list): "+v.Name)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
if !v.UserData.LastPlayedDate.IsZero() {
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
_, err := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED_JF,
CustomDate: &v.UserData.LastPlayedDate,
},
false,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
@@ -210,27 +245,65 @@ func (s *SyncService) startJellyfinSync(
job.UpdateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
// 2. Imported watched series
w, err := s.wp.AddWatched(userId, domain.WatchedAddRequest{
Status: entity.FINISHED,
ContentType: util.SupportedMediaShow,
TMDBID: tmdbId,
WatchedDate: v.UserData.LastPlayedDate,
}, entity.IMPORTED_WATCHED_JF)
w, err := s.wp.AddWatched(
userId,
domain.WatchedAddRequest{
Status: entity.FINISHED,
ContentType: util.SupportedMediaShow,
TMDBID: tmdbId,
WatchedDate: v.UserData.LastPlayedDate,
},
domain.WatchedAddExtraProps{
ActivityType: entity.IMPORTED_WATCHED_JF,
DontRestore: true,
})
if err != nil {
if err.Error() == "content already on watched list" {
slog.Info("jellyfinSyncWatched: Unique constraint hit.. content must already be on watch list.",
"series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "watched_id", w.ID)
if errors.Is(err, domain.ErrWatchedExists) {
slog.Info("jellyfinSyncWatched: Content already exists on list.",
"series_name", v.Name,
"series_ids", v.ProviderIds,
"user_id", userId,
"watched_id", w.ID)
// In this case, we allow continuing below to start syncing seasons/episodes
} else if errors.Is(err, domain.ErrWatchedExistsSoftDeleted) {
// Entry was manually soft deleted by user at another point,
// we DontRestore these automatically. We also don't continue
// because eps/seasons cant be synced.
slog.Warn("jellyfinSyncWatched: Show exists on list soft deleted.")
job.AddJobError(
jobId,
userId,
"failed to add show "+
v.Name+
". You have previously deleted it from your list!")
continue
} else {
slog.Error("jellyfinSyncWatched: Series failed to import.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
slog.Error("jellyfinSyncWatched: Series failed to import.",
"series_name", v.Name,
"series_ids", v.ProviderIds,
"user_id", userId,
"error", err)
job.AddJobError(jobId, userId, "series could not be imported (failed when adding to watched list): "+v.Name)
}
} else {
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
if !v.UserData.LastPlayedDate.IsZero() {
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
_, err := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED_JF,
CustomDate: &v.UserData.LastPlayedDate,
},
false,
)
if err != nil {
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.",
"series_name", v.Name,
"series_ids", v.ProviderIds,
"user_id", userId,
"date", v.UserData.LastPlayedDate,
"error", err)
}
}
}
+28 -7
View File
@@ -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
+58
View File
@@ -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)
}
})
}
+74 -29
View File
@@ -20,7 +20,7 @@ type PlexSyncResponse struct {
}
type WatchedProvider interface {
AddWatched(userId uint, ar domain.WatchedAddRequest, at entity.ActivityType) (entity.Watched, error)
AddWatched(userId uint, ar domain.WatchedAddRequest, extraProps domain.WatchedAddExtraProps) (entity.Watched, error)
}
type WatchedSeasonProvider interface {
@@ -115,16 +115,34 @@ func (s *SyncService) startPlexSync(
}
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
w, err := s.wp.AddWatched(userId, domain.WatchedAddRequest{
Status: entity.FINISHED,
TMDBID: tmdbId,
ContentType: util.SupportedMediaMovie,
Rating: float64(movie.UserRating),
WatchedDate: lastViewedAt,
}, entity.IMPORTED_WATCHED_PLEX)
w, err := s.wp.AddWatched(
userId,
domain.WatchedAddRequest{
Status: entity.FINISHED,
TMDBID: tmdbId,
ContentType: util.SupportedMediaMovie,
Rating: float64(movie.UserRating),
WatchedDate: lastViewedAt,
},
domain.WatchedAddExtraProps{
ActivityType: entity.IMPORTED_WATCHED_PLEX,
DontRestore: true,
})
if err != nil {
if err.Error() == "content already on watched list" {
slog.Error("plexSyncWatched: unique constraint hit. movie must already be on watch list", "error", err)
if errors.Is(err, domain.ErrWatchedExists) {
slog.Info("plexSyncWatched: Movie already exists on list.")
continue
}
if errors.Is(err, domain.ErrWatchedExistsSoftDeleted) {
// Entry was manually soft deleted by user at another point,
// we DontRestore these automatically.
slog.Warn("plexSyncWatched: Movie exists on list soft deleted.")
job.AddJobError(
jobId,
userId,
"failed to add movie "+
movie.Title+
". You have previously deleted it from your list!")
continue
}
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
@@ -132,11 +150,15 @@ func (s *SyncService) startPlexSync(
} else {
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
if !lastViewedAt.IsZero() {
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
})
_, err := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
},
false,
)
if err != nil {
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
@@ -182,16 +204,35 @@ func (s *SyncService) startPlexSync(
}
lastViewedAt := time.Unix(show.LastViewedAt, 0)
w, err := s.wp.AddWatched(userId, domain.WatchedAddRequest{
Status: entity.FINISHED,
ContentType: util.SupportedMediaShow,
TMDBID: tmdbId,
Rating: float64(show.UserRating),
WatchedDate: lastViewedAt,
}, entity.IMPORTED_WATCHED_PLEX)
w, err := s.wp.AddWatched(
userId,
domain.WatchedAddRequest{
Status: entity.FINISHED,
ContentType: util.SupportedMediaShow,
TMDBID: tmdbId,
Rating: float64(show.UserRating),
WatchedDate: lastViewedAt,
},
domain.WatchedAddExtraProps{
ActivityType: entity.IMPORTED_WATCHED_PLEX,
DontRestore: true,
})
if err != nil {
if err.Error() == "content already on watched list" {
slog.Info("plexSyncWatched: unique constraint hit. show must already be on watch list", "error", err)
if errors.Is(err, domain.ErrWatchedExists) {
slog.Info("plexSyncWatched: Show already exists on list.")
// In this case, we allow continuing below to start syncing seasons/episodes
} else if errors.Is(err, domain.ErrWatchedExistsSoftDeleted) {
// Entry was manually soft deleted by user at another point,
// we DontRestore these automatically. We also don't continue
// because eps/seasons cant be synced.
slog.Warn("plexSyncWatched: Show exists on list soft deleted.")
job.AddJobError(
jobId,
userId,
"failed to add show "+
show.Title+
". You have previously deleted it from your list!")
continue
} else {
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
job.AddJobError(jobId, userId, "failed to add show "+show.Title)
@@ -199,11 +240,15 @@ func (s *SyncService) startPlexSync(
} else {
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
if !lastViewedAt.IsZero() {
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
})
_, err := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
CustomDate: &lastViewedAt,
},
false,
)
if err != nil {
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
+29 -51
View File
@@ -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)
}
}
}
+81 -270
View File
@@ -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,
}
}
@@ -59,6 +48,8 @@ func (s *Service) Search(
pp util.PaginationParams,
userId uint,
) (domain.SearchResponse, error) {
slog.Debug("Search: Running.", "request", r, "user_id", userId)
resp := domain.SearchResponse{}
if r.Query == "" {
@@ -80,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")
}
}
@@ -110,12 +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.", "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")
@@ -127,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")
@@ -147,11 +178,11 @@ func (s *Service) searchMulti(
}
func (s *Service) searchMovie(
query string,
page int,
req tmdb.SearchMoviesOptions,
resp *domain.SearchResponse,
) error {
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")
@@ -172,7 +203,10 @@ func (s *Service) searchMovieById(
id string,
resp *domain.SearchResponse,
) error {
details, err := s.contentProvider.MovieDetails(id, "", map[string]string{})
slog.Debug("searchMovieById: Running.", "id", id)
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")
@@ -187,14 +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 {
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 {
@@ -213,7 +247,10 @@ func (s *Service) searchTvById(
id string,
resp *domain.SearchResponse,
) error {
details, err := s.contentProvider.TvDetails(id, "", map[string]string{})
slog.Debug("searchTvById: Running.", "id", id)
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")
@@ -229,11 +266,11 @@ func (s *Service) searchTvById(
}
func (s *Service) searchPeople(
query string,
page int,
req tmdb.SearchUniversalOptions,
resp *domain.SearchResponse,
) error {
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")
@@ -251,11 +288,11 @@ func (s *Service) searchPeople(
}
func (s *Service) searchGame(
query string,
page int,
req igdb.SearchOptions,
resp *domain.SearchResponse,
) error {
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")
@@ -276,6 +313,7 @@ func (s *Service) searchGameById(
id string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchGameById: Running.", "id", id)
igdbRes, err := s.cfg.TWITCH.SearchById(id)
if err != nil {
slog.Error("searchGameById: Failed to search igdb!", "error", err)
@@ -297,6 +335,7 @@ func (s *Service) searchGameBySlug(
slug string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchGameBySlug: Running.", "slug", slug)
igdbRes, err := s.cfg.TWITCH.SearchBySlug(slug)
if err != nil {
slog.Error("searchGameBySlug: Failed to search igdb!", "error", err)
@@ -342,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]
}
+238
View File
@@ -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]
}
+1 -1
View File
@@ -84,7 +84,7 @@ func (s *Service) GetTagPage(
// This isn't great, but it has to be done, since we can't eliminate
// any for sort/filters here.
wids := new([]int)
res := s.db.Debug().
res := s.db.
Table("watched_tags").
Select("watched_id").
Where("tag_id = ?", tagId).
+3 -1
View File
@@ -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
View File
@@ -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
}
+72 -19
View File
@@ -53,10 +53,6 @@ type WatchedSeasonProvider interface {
AddWatchedSeason(userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
}
type ContentProvider interface {
SeasonDetails(tvId string, seasonNumber string) (tmdb.TMDBSeasonDetails, error)
}
type UserProvider interface {
UserGetSettings(userId uint) (entity.UserSettings, error)
}
@@ -65,7 +61,7 @@ type Service struct {
db *gorm.DB
wp WatchedProvider
wsp WatchedSeasonProvider
cp ContentProvider
tmdb *tmdb.TMDB
activityProvider domain.ActivityAddProvider
userProvider UserProvider
}
@@ -74,7 +70,7 @@ func NewService(
db *gorm.DB,
wp WatchedProvider,
wsp WatchedSeasonProvider,
cp ContentProvider,
tmdb *tmdb.TMDB,
activityProvider domain.ActivityAddProvider,
userProvider UserProvider,
) *Service {
@@ -82,7 +78,7 @@ func NewService(
db,
wp,
wsp,
cp,
tmdb,
activityProvider,
userProvider,
}
@@ -143,24 +139,54 @@ func (s *Service) AddWatchedEpisodes(userId uint, ar WatchedEpisodeAddRequest) (
// (changing value to same value doesn't count).
if updated {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status})
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_STATUS_CHANGED, Data: string(json)})
json, _ := json.Marshal(map[string]any{
"season": ar.SeasonNumber,
"episode": ar.EpisodeNumber,
"status": ar.Status})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.EPISODE_STATUS_CHANGED,
Data: string(json),
},
false,
)
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_RATING_CHANGED, Data: string(json)})
json, _ := json.Marshal(map[string]any{
"season": ar.SeasonNumber,
"episode": ar.EpisodeNumber,
"rating": ar.Rating})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.EPISODE_RATING_CHANGED,
Data: string(json),
},
false,
)
}
}
} else {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
act := domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_ADDED, Data: string(json)}
json, _ := json.Marshal(map[string]any{
"season": ar.SeasonNumber,
"episode": ar.EpisodeNumber,
"status": ar.Status,
"rating": ar.Rating})
act := domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.EPISODE_ADDED,
Data: string(json),
}
if ar.AddActivity != "" {
act.Type = ar.AddActivity
}
if !ar.AddActivityDate.IsZero() {
act.CustomDate = &ar.AddActivityDate
}
addedActivity, _ = s.activityProvider.AddActivity(userId, act)
addedActivity, _ = s.activityProvider.AddActivity(userId, act, false)
}
episodeAddResp := WatchedEpisodeAddResponse{
WatchedEpisodes: w.WatchedEpisodes,
@@ -168,7 +194,13 @@ func (s *Service) AddWatchedEpisodes(userId uint, ar WatchedEpisodeAddRequest) (
}
if ar.Status != "" {
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
episodeAddResp.EpisodeStatusChangedHookResponse = s.hookEpisodeStatusChanged(userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
episodeAddResp.EpisodeStatusChangedHookResponse =
s.hookEpisodeStatusChanged(
userId,
ar.WatchedID,
ar.SeasonNumber,
ar.EpisodeNumber,
ar.Status)
}
return episodeAddResp, nil
}
@@ -177,7 +209,12 @@ func (s *Service) AddWatchedEpisodes(userId uint, ar WatchedEpisodeAddRequest) (
func (s *Service) rmWatchedEpisode(userId uint, id uint) (entity.Activity, error) {
slog.Debug("rmWatchedSeason called", "user_id", userId, "id", id)
var watchedEpisode entity.WatchedEpisode
resp := s.db.Clauses(clause.Returning{}).Model(&entity.WatchedEpisode{}).Unscoped().Where("id = ? AND user_id = ?", id, userId).Delete(&watchedEpisode)
resp := s.db.
Clauses(clause.Returning{}).
Model(&entity.WatchedEpisode{}).
Unscoped().
Where("id = ? AND user_id = ?", id, userId).
Delete(&watchedEpisode)
if resp.Error != nil {
slog.Error("Failed when removing a watched episode", "error", resp.Error)
return entity.Activity{}, errors.New("failed when removing watched episode")
@@ -194,7 +231,15 @@ func (s *Service) rmWatchedEpisode(userId uint, id uint) (entity.Activity, error
"status": watchedEpisode.Status,
"rating": watchedEpisode.Rating,
})
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedEpisode.WatchedID, Type: entity.EPISODE_REMOVED, Data: string(json)})
addedActivity, _ := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: watchedEpisode.WatchedID,
Type: entity.EPISODE_REMOVED,
Data: string(json),
},
false,
)
return addedActivity, nil
}
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
@@ -223,7 +268,15 @@ func (s *Service) hookEpisodeStatusChanged(userId uint, watchedId uint, seasonNu
hookResponse := EpisodeStatusChangedHookResponse{}
addHookActivity := func(aType entity.ActivityType, data string) {
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
addedActivity, _ := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: watchedId,
Type: aType,
Data: (data),
},
false,
)
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
}
@@ -306,7 +359,7 @@ func (s *Service) hookEpisodeStatusChanged(userId uint, watchedId uint, seasonNu
// to Watching just above. I think this might never happen to anyone so um ye.
tmdbIdStr := strconv.Itoa(watchedShow.Content.TmdbID)
seasonNumStr := strconv.Itoa(seasonNum)
seasonDetails, err := s.cp.SeasonDetails(tmdbIdStr, seasonNumStr)
seasonDetails, err := s.tmdb.SeasonDetails(tmdbIdStr, seasonNumStr)
if err != nil {
slog.Error("hookEpisodeStatusChanged: Failed to get season details!", "error", err)
hookResponse.Errors = append(hookResponse.Errors, "failed to get season details for show")
+10 -10
View File
@@ -9,25 +9,18 @@ import (
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/domain"
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
"github.com/sbondCo/Watcharr/media/tmdb"
"github.com/sbondCo/Watcharr/router"
"github.com/sbondCo/Watcharr/util"
)
type Router struct {
br *router.BaseRouter
t *tmdb.TMDB
s *Service
}
func NewRouter(
br *router.BaseRouter,
t *tmdb.TMDB,
service *Service,
) *Router {
func NewRouter(br *router.BaseRouter, service *Service) *Router {
return &Router{
br: br,
t: t,
s: service,
}
}
@@ -120,12 +113,19 @@ func (r *Router) AddWatched(c *gin.Context) {
var ar domain.WatchedAddRequest
err := c.ShouldBindJSON(&ar)
if err == nil {
response, err := r.s.AddWatched(userId, ar, entity.ADDED_WATCHED)
newWatched, err := r.s.AddWatched(
userId,
ar,
domain.WatchedAddExtraProps{
ActivityType: entity.ADDED_WATCHED,
},
)
if err != nil {
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
return
}
c.JSON(http.StatusOK, response)
dto := domain.NewWatchedDtoForContentPage(&newWatched)
c.JSON(http.StatusOK, dto)
return
}
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})

Some files were not shown because too many files have changed in this diff Show More