Compare commits

...

54 Commits

Author SHA1 Message Date
ImgBotApp eb2bd61f4a [ImgBot] Optimize images
*Total -- 2,812.28kb -> 2,350.80kb (16.41%)

/changelog/1.23.0/0.png -- 37.53kb -> 24.38kb (35.05%)
/changelog/1.40.0/1.png -- 34.35kb -> 23.57kb (31.37%)
/changelog/1.29.0/0.png -- 85.35kb -> 61.36kb (28.1%)
/changelog/1.27.0/0.png -- 26.56kb -> 19.86kb (25.24%)
/changelog/1.31.1/0.png -- 9.08kb -> 6.83kb (24.69%)
/changelog/1.10.0/3.png -- 15.57kb -> 11.77kb (24.41%)
/changelog/1.18.0/0.png -- 32.55kb -> 24.71kb (24.09%)
/changelog/1.32.1/0.png -- 14.76kb -> 11.33kb (23.2%)
/changelog/1.40.0/0.png -- 41.21kb -> 31.83kb (22.78%)
/changelog/1.10.0/2.png -- 35.97kb -> 28.14kb (21.76%)
/changelog/2.0.1/0.png -- 5.08kb -> 4.01kb (20.9%)
/changelog/1.19.0/3.png -- 19.12kb -> 15.14kb (20.8%)
/changelog/1.26.0/0.png -- 8.39kb -> 6.66kb (20.63%)
/changelog/1.37.0/0.png -- 32.42kb -> 25.74kb (20.59%)
/changelog/1.44.0/0.png -- 19.59kb -> 15.60kb (20.38%)
/changelog/2.0.1/1.png -- 13.79kb -> 11.10kb (19.5%)
/changelog/3.0.1/0.png -- 359.62kb -> 290.87kb (19.12%)
/changelog/3.0.0/0.png -- 878.31kb -> 710.75kb (19.08%)
/changelog/1.19.0/0.png -- 8.41kb -> 6.83kb (18.74%)
/changelog/1.44.0/1.png -- 7.89kb -> 6.42kb (18.6%)
/changelog/1.38.0/0.png -- 273.00kb -> 225.57kb (17.37%)
/changelog/1.19.0/2.png -- 6.97kb -> 5.76kb (17.36%)
/changelog/1.19.0/1.png -- 30.79kb -> 25.85kb (16.06%)
/changelog/1.19.0/4.png -- 17.00kb -> 14.44kb (15.02%)
/changelog/1.35.0/0.png -- 15.16kb -> 12.92kb (14.76%)
/changelog/1.10.0/0.png -- 6.36kb -> 5.44kb (14.46%)
/changelog/1.39.0/0.png -- 118.07kb -> 101.55kb (13.99%)
/changelog/1.17.0/0.png -- 55.26kb -> 48.11kb (12.94%)
/changelog/1.26.0/1.png -- 27.83kb -> 24.28kb (12.74%)
/changelog/1.31.0/0.png -- 61.73kb -> 54.37kb (11.93%)
/changelog/1.10.0/1.png -- 89.90kb -> 79.62kb (11.43%)
/changelog/1.31.0/1.png -- 22.03kb -> 20.07kb (8.9%)
/changelog/1.20.0/0.png -- 118.29kb -> 114.04kb (3.59%)
/changelog/1.20.0/1.png -- 284.37kb -> 281.87kb (0.88%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2026-07-05 17:40:56 +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
99 changed files with 3844 additions and 647 deletions
+1732
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 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)
+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
@@ -1,12 +1,12 @@
{
"name": "watcharr",
"version": "3.0.0",
"version": "3.0.2-dev1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "watcharr",
"version": "3.0.0",
"version": "3.0.2-dev1",
"license": "GPL-3.0-only",
"dependencies": {
"axios": "^1.9.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "watcharr",
"license": "GPL-3.0-only",
"version": "3.0.0",
"version": "3.0.2-dev1",
"private": true,
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1 +1 @@
3.0.0
3.0.2-dev1
+130
View File
@@ -1,26 +1,40 @@
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).
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
}
if err := configure(db); err != nil {
slog.Error("New: Configuring connection failed!", "error", err)
return nil, err
}
// Perform auto migration.
slog.Info("New: AutoMigrating")
err = db.AutoMigrate(
&migrate.MigrationRecord{},
&entity.User{},
&entity.UserServices{},
&entity.Content{},
@@ -36,7 +50,123 @@ func New() (*gorm.DB, error) {
&entity.Tag{},
)
if err != nil {
slog.Error("New: Auto migration failed.")
return nil, err
}
slog.Info("New: AutoMigrated")
// Perform our manual migrations.
if err := migrate.Now(db); err != nil {
slog.Error("New: Manual migrations failed.", "error", err)
return nil, err
}
// Optimize database.
if err := optimize(db); err != nil {
slog.Error("New: Optimizing database failed.", "error", err)
return nil, err
}
return db, 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"`
}
+14
View File
@@ -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
}
+100
View File
@@ -0,0 +1,100 @@
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
}
// 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.Debug("Migration applied successfully.",
"id", mig.ID,
"duration", time.Since(timeBeforeMig))
}
slog.Info("Done processing all migrations.")
return nil
}
+138
View File
@@ -0,0 +1,138 @@
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,
// TODO: Should these be here?:
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 {
+1
View File
@@ -98,6 +98,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)
+141 -170
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)
}
@@ -32,10 +31,7 @@ type WatchedEpisodeProvider interface {
}
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 {
@@ -43,6 +39,10 @@ type TagProvider interface {
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
@@ -51,6 +51,7 @@ type Service struct {
cp ContentProvider
activityProvider domain.ActivityAddProvider
tagProvider TagProvider
searchProvider SearchProvider
}
func NewService(
@@ -61,6 +62,7 @@ func NewService(
cp ContentProvider,
activityProvider domain.ActivityAddProvider,
tagProvider TagProvider,
searchProvider SearchProvider,
) *Service {
return &Service{
db,
@@ -70,166 +72,64 @@ func NewService(
cp,
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 +143,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 +251,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 +281,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 +301,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 +317,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 +328,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 +338,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.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,
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)
}
}
}
+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)
+11
View File
@@ -59,6 +59,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 == "" {
@@ -114,6 +116,7 @@ func (s *Service) searchMulti(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchMulti: Running.", "query", query, "page", page)
// TMDB
tmdbRes, err := s.contentProvider.SearchContent(query, page)
if err != nil {
@@ -151,6 +154,7 @@ func (s *Service) searchMovie(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchMovie: Running.", "query", query, "page", page)
tmdbRes, err := s.contentProvider.SearchMovies(query, page)
if err != nil {
slog.Error("SearchMovie: Failed to search tmdb!", "error", err)
@@ -172,6 +176,7 @@ func (s *Service) searchMovieById(
id string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchMovieById: Running.", "id", id)
details, err := s.contentProvider.MovieDetails(id, "", map[string]string{})
if err != nil {
slog.Error("searchMovieById: Failed to search tmdb!", "error", err)
@@ -192,6 +197,7 @@ func (s *Service) searchTv(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchTv: Running.", "query", query, "page", page)
tmdbRes, err := s.contentProvider.SearchTv(query, page)
if err != nil {
slog.Error("searchTv: Failed to search tmdb!", "error", err)
@@ -213,6 +219,7 @@ func (s *Service) searchTvById(
id string,
resp *domain.SearchResponse,
) error {
slog.Debug("searchTvById: Running.", "id", id)
details, err := s.contentProvider.TvDetails(id, "", map[string]string{})
if err != nil {
slog.Error("searchTvById: Failed to search tmdb!", "error", err)
@@ -233,6 +240,7 @@ func (s *Service) searchPeople(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchPeople: Running.", "query", query, "page", page)
tmdbRes, err := s.contentProvider.SearchPeople(query, page)
if err != nil {
slog.Error("searchPeople: Failed to search tmdb!", "error", err)
@@ -255,6 +263,7 @@ func (s *Service) searchGame(
page int,
resp *domain.SearchResponse,
) error {
slog.Debug("searchGame: Running.", "query", query, "page", page)
igdbRes, err := s.cfg.TWITCH.Search(query)
if err != nil {
slog.Error("searchGame: Failed to search igdb!", "error", err)
@@ -276,6 +285,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 +307,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)
+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).
+68 -11
View File
@@ -143,24 +143,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 +198,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 +213,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 +235,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 +272,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)
}
+9 -2
View File
@@ -120,12 +120,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()})
+40 -7
View File
@@ -46,7 +46,11 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
// 1. Make sure watched item exists and it is the correct type (TV)
var w entity.Watched
if resp := s.db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedSeasons").Find(&w); resp.Error != nil {
if resp := s.db.
Where("id = ? AND user_id = ?", ar.WatchedID, userId).
Preload("Content").
Preload("WatchedSeasons").
Find(&w); resp.Error != nil {
slog.Error("Failed when adding a watched season", "error", "failed to get watched item from db")
return WatchedSeasonAddResponse{}, errors.New("failed when retrieving watched item")
}
@@ -96,11 +100,27 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
if updated {
if ar.Status != "" {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_STATUS_CHANGED, Data: string(json)})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.SEASON_STATUS_CHANGED,
Data: string(json),
},
false,
)
}
if ar.Rating != 0 {
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_RATING_CHANGED, Data: string(json)})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: w.ID,
Type: entity.SEASON_RATING_CHANGED,
Data: string(json),
},
false,
)
}
}
} else {
@@ -113,14 +133,14 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
}
}
json, _ := json.Marshal(actData)
act := domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_ADDED, Data: string(json)}
act := domain.ActivityAddProps{WatchedID: w.ID, Type: entity.SEASON_ADDED, Data: string(json)}
if ar.AddActivity != "" {
act.Type = ar.AddActivity
}
if !ar.AddActivityDate.IsZero() {
act.CustomDate = &ar.AddActivityDate
}
addedActivity, _ = s.activityProvider.AddActivity(userId, act)
addedActivity, _ = s.activityProvider.AddActivity(userId, act, false)
}
return WatchedSeasonAddResponse{
WatchedSeasons: w.WatchedSeasons,
@@ -132,7 +152,12 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
func (s *Service) RmWatchedSeason(userId uint, seasonId uint) (entity.Activity, error) {
slog.Debug("rmWatchedSeason called", "user_id", userId, "season_id", seasonId)
var watchedSeason entity.WatchedSeason
resp := s.db.Clauses(clause.Returning{}).Model(&entity.WatchedSeason{}).Unscoped().Where("id = ? AND user_id = ?", seasonId, userId).Delete(&watchedSeason)
resp := s.db.
Clauses(clause.Returning{}).
Model(&entity.WatchedSeason{}).
Unscoped().
Where("id = ? AND user_id = ?", seasonId, userId).
Delete(&watchedSeason)
if resp.Error != nil {
slog.Error("Failed when removing a watched season", "error", resp.Error)
return entity.Activity{}, errors.New("failed when removing watched season")
@@ -148,7 +173,15 @@ func (s *Service) RmWatchedSeason(userId uint, seasonId uint) (entity.Activity,
"status": watchedSeason.Status,
"rating": watchedSeason.Rating,
})
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedSeason.WatchedID, Type: entity.SEASON_REMOVED, Data: string(json)})
addedActivity, _ := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: watchedSeason.WatchedID,
Type: entity.SEASON_REMOVED,
Data: string(json),
},
false,
)
return addedActivity, nil
}
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
+148 -54
View File
@@ -6,6 +6,7 @@ import (
"log/slog"
"strconv"
"github.com/sbondCo/Watcharr/database/dbmodel"
"github.com/sbondCo/Watcharr/database/entity"
"github.com/sbondCo/Watcharr/domain"
"github.com/sbondCo/Watcharr/feature/watched/addedtocontent"
@@ -99,17 +100,18 @@ func (s *Service) GetWatchedPage(
Preload("Tags").
Preload("WatchedSeasons").
Preload("WatchedEpisodes").
// Refine our results first (filters, sort);
Scopes(
watchedRefine(wr),
).
// Apply filters first.
Scopes(watchedRefineFilter(wr)).
// Then count results (after filter);
Count(&pRes.TotalResults).
// Now calculate pagination properties with a TotalResults
// that takes filtered out items into account.
Scopes(
util.Paginate(pp, pRes),
).
Scopes(util.Paginate(pp, pRes)).
// Last we can apply our sorting.
// Note: We must sort *after* we count, because currently
// our 'Last Finished' sort, causes the COUNT query to also
// be given extra JOINS that we don't want (because it slows it down).
Scopes(watchedRefineSort(wr, userId)).
Find(&watched)
if res.Error != nil {
slog.Error("GetWatchedPage: Failed!", "error", res.Error)
@@ -161,17 +163,16 @@ func (s *Service) getPublicWatched(
Preload("Tags").
Preload("WatchedSeasons").
Preload("WatchedEpisodes").
// Refine our results first (filters, sort);
Scopes(
watchedRefine(wr),
).
// Apply filters first.
Scopes(watchedRefineFilter(wr)).
// Then count results (after filter);
Count(&pRes.TotalResults).
// Now calculate pagination properties with a TotalResults
// that takes filtered out items into account.
Scopes(
util.Paginate(pp, pRes),
).
Scopes(util.Paginate(pp, pRes)).
// Sort options.
// Note: See note above in GetWatchedPage.
Scopes(watchedRefineSort(wr, userId)).
Find(&watched)
if res.Error != nil {
slog.Error("getPublicWatched: Failed!", "error", res.Error)
@@ -185,7 +186,10 @@ func (s *Service) getPublicWatched(
// Get a watched list item by id (must be for `userId`).
func (s *Service) GetWatchedItemById(userId uint, id uint) (entity.Watched, error) {
watched := new(entity.Watched)
res := s.db.Model(&entity.Watched{}).Preload("Content").Where("user_id = ? AND id = ?", userId, id).Find(&watched)
res := s.db.Model(&entity.Watched{}).
Preload("Content").
Where("user_id = ? AND id = ?", userId, id).
Find(&watched)
if res.Error != nil {
slog.Error("GetWatchedItemById: Failed!", "error", res.Error)
return entity.Watched{}, res.Error
@@ -341,11 +345,17 @@ func (s *Service) GetWatchedItemsBySupportedMediaIds(userId uint, c []addedtocon
func (s *Service) AddWatched(
userId uint,
ar domain.WatchedAddRequest,
at entity.ActivityType,
extraProps domain.WatchedAddExtraProps,
) (entity.Watched, error) {
slog.Debug("Adding watched item",
"user_id", userId,
"add_request", ar)
"add_request", ar,
"extra_props", extraProps)
if extraProps.ActivityType == "" {
// We ALWAYS require the ActivityType to be provided by any caller.
return entity.Watched{}, errors.New("extraProp ActivityType not specified")
}
watched := entity.Watched{
UserID: userId,
@@ -422,11 +432,21 @@ func (s *Service) AddWatched(
// Create the entry in db.
res := s.db.Create(&watched)
if res.Error != nil {
if res.Error == gorm.ErrDuplicatedKey {
// Try to restore the entry if unique contraint hit.
if err := s.restoreWatched(userId, ar, &watched); err != nil {
if errors.Is(res.Error, gorm.ErrDuplicatedKey) {
// The record already exists.
// It could be soft deleted or not.
slog.Info("AddWatched: Creating row failed because of a unique key constraint violation.")
if err := s.restoreWatchedAfterDuplicatedKeyErr(
userId,
ar,
extraProps.DontRestore,
&watched,
); err != nil {
// Try to restore the entry if unique contraint hit.
slog.Error("AddWatched: Failed to restore existing watched entry.")
return entity.Watched{}, errors.New("failed when restoring existing entry")
// Returns watched too because handlers of certain errors
// may need it (and it's ID since we could have fetched it here)
return watched, err
}
} else {
slog.Error("AddWatched: Error adding watched to database", "error", res.Error.Error())
@@ -436,9 +456,9 @@ func (s *Service) AddWatched(
slog.Debug("AddWatched: Added watched list item", "item", watched)
// Finally add activity
activityAddReq := domain.ActivityAddRequest{
activityAddReq := domain.ActivityAddProps{
WatchedID: watched.ID,
Type: at,
Type: extraProps.ActivityType,
}
if activityJson, err := json.Marshal(map[string]any{
"status": ar.Status,
@@ -449,9 +469,14 @@ func (s *Service) AddWatched(
} else {
activityAddReq.Data = string(activityJson)
}
countAsPlay := false
if ar.Status == entity.FINISHED {
countAsPlay = true
}
act, _ := s.activityProvider.AddActivity(
userId,
activityAddReq,
countAsPlay,
)
watched.Activity = append(watched.Activity, act)
@@ -461,12 +486,16 @@ func (s *Service) AddWatched(
// Restore a watched entry that was soft deleted.
// Currently used for AddWatched, when it realizes the entry may exist already
// as a soft deleted record.
func (s *Service) restoreWatched(
//
// NOTE: This function assumes it is being ran after a unique constraint
// violation (like in AddWatched).
func (s *Service) restoreWatchedAfterDuplicatedKeyErr(
userId uint,
ar domain.WatchedAddRequest,
dontRestore bool,
watchedOut *entity.Watched,
) error {
slog.Info("restoreWatched: Attempting to restore.",
slog.Info("restoreWatchedAfterDuplicatedKeyErr: Attempting to restore.",
"user_id", userId)
// Our base where statement to find the row we want to restore by unique
@@ -489,40 +518,60 @@ func (s *Service) restoreWatched(
return errors.New("no supported media ids in provided watched struct")
}
// Try to restore and update the possibly existing row.
res := s.db.Debug().Model(&entity.Watched{}).
// First we will try to get the record.
res := s.db.
Model(&entity.Watched{}).
Unscoped().
// Just loading activity now too so if we restore record,
// we can give the full activity in response.
Preload("Activity").
Where(&whereStmt).
Where("deleted_at IS NOT NULL").
Take(&watchedOut)
if res.Error != nil {
slog.Error("restoreWatchedAfterDuplicatedKeyErr: Select query failed!",
"error", res.Error)
return errors.New("errored checking for potential soft deleted record")
}
if watchedOut.ID == 0 {
return errors.New("didn't find an existing record")
}
if watchedOut.DeletedAt.Time.IsZero() {
// If it's not deleted, then it just exists..
return domain.ErrWatchedExists
} else if dontRestore {
// If we are told not to attempt a restore (eg by a sync-er).
return domain.ErrWatchedExistsSoftDeleted
}
// If we are here, we have a soft deleted record to restore:
res = s.db.
// Passing `&watchedOut` makes gorm do another query for inserting
// all existing rows into db, im not sure why... but just going to
// manually pass primary key in.
Model(&entity.Watched{
GormModel: dbmodel.GormModel{ID: watchedOut.ID},
}).
Unscoped().
Updates(map[string]any{
"status": ar.Status,
"rating": ar.Rating,
"thoughts": ar.Thoughts,
"deleted_at": nil,
})
if res.Error != nil {
slog.Error("restoreWatched: Checking for record failed!",
"error", res.Error)
return errors.New("errored checking for soft deleted record")
if res.Error != nil || res.RowsAffected == 0 {
slog.Error("restoreWatchedAfterDuplicatedKeyErr: Updating record failed!",
"error", res.Error,
"rows_affected", res.RowsAffected)
return errors.New("failed to update record")
}
if res.RowsAffected == 0 {
slog.Error("restoreWatched: Nothing was updated. The row may already exist un-deleted.")
return errors.New("didnt find an entry to restore")
}
slog.Info("restoreWatched: Restored record.",
"user_id", userId)
// We have updated the row, update our struct now.
watchedOut.Status = ar.Status
watchedOut.Rating = ar.Rating
watchedOut.Thoughts = ar.Thoughts
watchedOut.DeletedAt = gorm.DeletedAt{}
// Restore query above succeeded so now lets get all data needed and return.
res = s.db.Debug().Model(&entity.Watched{}).
Unscoped().
Preload("Activity").
Where(&whereStmt).
Take(&watchedOut)
if res.Error != nil {
slog.Error("restoreWatched: Getting updated record failed!",
"error", res.Error)
return errors.New("errored while trying to get updated record")
}
slog.Info("restoreWatchedAfterDuplicatedKeyErr: Updated record.",
"user_id", userId)
return nil
}
@@ -534,6 +583,11 @@ func (s *Service) updateWatched(
ar domain.WatchedUpdateRequest,
) (domain.WatchedUpdateResponse, error) {
slog.Debug("UpdateWatched", "request_data", ar)
if err := ar.Valid(); err != nil {
slog.Error("UpdateWatched: UpdateRequest struct is invalid.",
"id", id, "error", err)
return domain.WatchedUpdateResponse{}, err
}
upwat := entity.Watched{}
res := s.db.Model(&entity.Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
if res.Error != nil {
@@ -562,16 +616,49 @@ func (s *Service) updateWatched(
}
addedActivity := entity.Activity{}
if ar.Rating != 0 {
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.RATING_CHANGED, Data: strconv.Itoa(int(ar.Rating))})
addedActivity, _ = s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: id,
Type: entity.RATING_CHANGED,
Data: strconv.Itoa(int(ar.Rating)),
},
false,
)
}
if ar.Status != "" {
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.STATUS_CHANGED, Data: string(ar.Status)})
countAsPlay := false
if ar.Status == entity.FINISHED &&
util.Deref(ar.LetCountAsPlay, true) != false {
countAsPlay = true
}
addedActivity, _ = s.activityProvider.AddActivity(userId,
domain.ActivityAddProps{
WatchedID: id,
Type: entity.STATUS_CHANGED,
Data: string(ar.Status),
},
countAsPlay,
)
}
if ar.Thoughts != "" {
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.THOUGHTS_CHANGED})
addedActivity, _ = s.activityProvider.AddActivity(userId,
domain.ActivityAddProps{
WatchedID: id,
Type: entity.THOUGHTS_CHANGED,
},
false,
)
}
if ar.RemoveThoughts {
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.THOUGHTS_REMOVED, Data: originalThoughts})
addedActivity, _ = s.activityProvider.AddActivity(userId,
domain.ActivityAddProps{
WatchedID: id,
Type: entity.THOUGHTS_REMOVED,
Data: originalThoughts,
},
false,
)
}
return domain.WatchedUpdateResponse{NewActivity: addedActivity}, nil
}
@@ -624,6 +711,13 @@ func (s *Service) removeWatched(
if res.RowsAffected <= 0 {
return domain.WatchedRemoveResponse{}, errors.New("no watched entry found")
}
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: id, Type: entity.REMOVED_WATCHED})
addedActivity, _ := s.activityProvider.AddActivity(
userId,
domain.ActivityAddProps{
WatchedID: id,
Type: entity.REMOVED_WATCHED,
},
false,
)
return domain.WatchedRemoveResponse{NewActivity: addedActivity}, nil
}
+36 -13
View File
@@ -50,7 +50,14 @@ func refineFilterStatus(db *gorm.DB, f []entity.WatchedStatus) {
}
// Applies sorts to list.
func refineSort(db *gorm.DB, sort domain.WatchedSort, dir domain.SortDirection) {
// Takes in userId of user who owns the list we are sorting, since some sorts
// may require it for subqueries (eg LastFinished).
func refineSort(
db *gorm.DB,
userId uint,
sort domain.WatchedSort,
dir domain.SortDirection,
) {
if sort == "" {
return
}
@@ -71,19 +78,27 @@ func refineSort(db *gorm.DB, sort domain.WatchedSort, dir domain.SortDirection)
db.Order(obc(clause.Column{Name: "watcheds.updated_at"}))
case domain.WatchedSortLastFinished:
db.
// This join looks for the latest activity for each watched entry
// that indiciates a 'FINISHED' status. The date of these is used
// in the sort below.
// This seems the best way to support this sort with how our current
// activity data is structured.
// This join looks for the latest activity that counts as a play
// for each watched entry. The date of these is used in the sort
// below.
// Note: Technically the join subquery will process ALL activities
// that the user has by their user_id, BUT this is okay since we
// use this sorting over the users entire watched list, so we want
// to process every activity to join to main list for the sort
// anyways. I'm making this note because previously I had left out
// the user_id WHERE, which results in all activities in the table
// being processed, which is obviously NOT wanted (cuz its slower).
Joins(`LEFT JOIN (
SELECT
watched_id AS a_watched_id,
MAX(COALESCE(custom_date, created_at)) AS a_sort_by_date
FROM activities
WHERE data LIKE "%FINISHED%" AND deleted_at IS NULL
WHERE
count_as_play = 1
AND deleted_at IS NULL
AND user_id = ?
GROUP BY watched_id
) q ON q.a_watched_id = watcheds.id`).
) q ON q.a_watched_id = watcheds.id`, userId).
Order(obc(clause.Column{Name: "q.a_sort_by_date"}))
case domain.WatchedSortRating:
db.Order(obc(clause.Column{Name: "watcheds.rating"}))
@@ -109,15 +124,23 @@ func refineSortPinned(db *gorm.DB) {
}
// list data.
// gorm scope for applying sort and filters to watched
func watchedRefine(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
// gorm scope for applying filters to watched
func watchedRefineFilter(wr domain.WatchedGetPageRequest) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// Apply filters
refineFilterType(db, wr.FilterType)
refineFilterStatus(db, wr.FilterStatus)
// Apply sort
refineSortPinned(db)
refineSort(db, wr.Sort, wr.SortDir)
return db
}
}
// list data.
// gorm scope for applying sort to watched
func watchedRefineSort(wr domain.WatchedGetPageRequest, userId uint) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {
// Apply sort
refineSortPinned(db)
refineSort(db, userId, wr.Sort, wr.SortDir)
return db
}
}
@@ -25,28 +25,28 @@ func TestGetLatestWatchedInTv(t *testing.T) {
}
watchedEps := []entity.WatchedEpisode{
{
GormModel: dbmodel.GormModel{ID: 60},
EpisodeNumber: 1,
SeasonNumber: 1,
Status: entity.FINISHED,
GormModelNoDel: dbmodel.GormModelNoDel{ID: 60},
EpisodeNumber: 1,
SeasonNumber: 1,
Status: entity.FINISHED,
},
{
GormModel: dbmodel.GormModel{ID: 70},
EpisodeNumber: 5,
SeasonNumber: 2,
Status: entity.FINISHED,
GormModelNoDel: dbmodel.GormModelNoDel{ID: 70},
EpisodeNumber: 5,
SeasonNumber: 2,
Status: entity.FINISHED,
},
{
GormModel: dbmodel.GormModel{ID: 72},
EpisodeNumber: 6,
SeasonNumber: 3,
Status: entity.DROPPED,
GormModelNoDel: dbmodel.GormModelNoDel{ID: 72},
EpisodeNumber: 6,
SeasonNumber: 3,
Status: entity.DROPPED,
},
{
GormModel: dbmodel.GormModel{ID: 90},
EpisodeNumber: 2,
SeasonNumber: 3,
Status: entity.FINISHED,
GormModelNoDel: dbmodel.GormModelNoDel{ID: 90},
EpisodeNumber: 2,
SeasonNumber: 3,
Status: entity.FINISHED,
},
}
resp := watchedutil.GetLatestWatchedInTv(watchedSeasons, watchedEps)
+3 -2
View File
@@ -68,7 +68,7 @@ func (i *IGDB) req(host string, ep string, p map[string]string, b string, resp i
// Add params to url
base.RawQuery = params.Encode()
slog.Info("req", "base", base.String())
slog.Debug("req", "base", base.String())
req, err := http.NewRequest("POST", base.String(), bytes.NewBuffer([]byte(b)))
if err != nil {
@@ -137,7 +137,8 @@ func (i *IGDB) refreshToken(ctx context.Context) {
slog.Info("IGDB refreshToken: Token expired (or is near expiry date)")
r, err := i.getNewAccessToken()
if err != nil {
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):", err)
slog.Error("IGDB refreshToken: Error refreshing token (retrying in 60s):",
"error", err)
exp = time.After(60 * time.Second)
} else {
slog.Info("IGDB refreshToken: Token successfully refreshed")
+7
View File
@@ -7,6 +7,7 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/sbondCo/Watcharr/config"
"github.com/sbondCo/Watcharr/database"
"github.com/sbondCo/Watcharr/feature/arr"
"github.com/sbondCo/Watcharr/image"
"github.com/sbondCo/Watcharr/token"
@@ -74,6 +75,12 @@ func SetupTasks(cfg *config.ServerConfig, db *gorm.DB) {
},
dd: 24 * time.Hour,
},
"Optimize Database": {
f: func() {
database.TaskOptimize(db)
},
dd: 24 * time.Hour,
},
}
// Add all jobs to scheduler.
+6
View File
@@ -4,6 +4,12 @@ import "time"
// Returns age in years.
func GetAge(birthDate time.Time, deathDate time.Time) int {
if birthDate.IsZero() {
// If we don't have a birthday, skip processing.
return 0
}
// Calculate an endDate, which is either `deathDate` or `now`.
var endDate time.Time
if !deathDate.IsZero() {
endDate = deathDate
+11
View File
@@ -0,0 +1,11 @@
package util
// Safe dereferencing of pointers to make
// our core logic more readable.
// If the ptr is nil, def (a default value) is returned.
func Deref[T any](ptr *T, def T) T {
if ptr == nil {
return def
}
return *ptr
}
+16 -6
View File
@@ -66,7 +66,13 @@ func main() {
}
multiw := logging.Setup(path.Join(config.DataPath, "watcharr.log"))
slog.Info("Watcharr Starting", "version", version)
// Just a nice separator so when inspecting the log file I know when
// logs are for a new instance.
fmt.Fprintf(multiw, `
Stand back. Watcharr is starting.
`)
slog.Info("Watcharr starting", "version", version)
fmt.Printf(`
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣀⣀⣀⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣠⣴⣶⣿⠿⠛⠛⠛⠻⠿⣿⣿⣿⣿⣿⣶⣤⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
@@ -110,7 +116,7 @@ func main() {
╚══╝╚══╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝
Layer:%s Starting now. Get ready!
Thank you for running my (spaghetti) code on your system.`+"\n\n", version)
// Ensure data dir exists
@@ -133,9 +139,12 @@ func main() {
isProd = false
}
// Create our database connection.
// Migrations are ran before our connection is returned for use.
db, err := database.New()
if err != nil {
log.Fatal("Failed to connect to database:", err)
slog.Error("Database initialization failed!", "error", err)
os.Exit(1)
}
if isProd {
@@ -223,6 +232,8 @@ func main() {
profileService := profile.NewService(db)
followService := follow.NewService(db)
tagService := tag.NewService(db, watchedService)
searchService := search.NewService(db, br.Cfg, contentService, watchedService)
discoverService := discover.NewService(db, br.Cfg, contentService)
importService := imprt.NewService(
db,
watchedService,
@@ -230,10 +241,9 @@ func main() {
watchedEpisodeService,
contentService,
activityService,
tagService)
tagService,
searchService)
importTraktService := imprt.NewTraktService(importService)
searchService := search.NewService(db, br.Cfg, contentService, watchedService)
discoverService := discover.NewService(db, br.Cfg, contentService)
auth.NewRouter(br, authService, authTrustedHeaderService).AddRoutes()
content.NewRouter(br, contentService, watchedService).AddRoutes()
+18 -8
View File
@@ -11,13 +11,10 @@
interface Props {
activity: Activity[] | undefined;
onRemoved: (activity: Activity) => void;
}
let {
// Bindable so we can update/delete activity and reflect
// the change upstream.
activity = $bindable(undefined),
}: Props = $props();
let { activity = undefined, onRemoved }: Props = $props();
let clickedActivity: Activity | undefined = $state();
let groupedActivities: { [index: string]: any } = $derived(
@@ -218,14 +215,14 @@
const ai = activity.findIndex((a) => a.id === activityId);
activity[ai] = updatedActivity;
}}
onRemoved={(activityId) => {
onRemoved={(a) => {
if (!activity) {
console.error(
"ActivityEditor->onRemoved: 'activity' doesn't exist somehow..",
);
return;
}
activity = activity.filter((a) => a.id !== activityId);
onRemoved(a);
}}
/>
{/if}
@@ -252,13 +249,24 @@
data && data.reason
? `Automated because ${data.reason}`
: "Completed by an automation.",
pos: "bot",
pos: "top",
}}
style="width: 20px; height: 20px;"
>
<Icon i="sparkles" wh={20} />
</i>
{/if}
{#if a.countAsPlay}
<i
use:tooltip={{
text: "Counts as a Play.",
pos: "top",
}}
style="width: 20px; height: 20px;"
>
<Icon i="play" wh={20} />
</i>
{/if}
</li>
{/each}
{/each}
@@ -307,8 +315,10 @@
display: flex;
flex-flow: row;
align-items: center;
flex-shrink: 1;
gap: 8px;
width: max-content;
min-width: 0;
max-width: 100%;
cursor: pointer;
}
+2 -2
View File
@@ -8,7 +8,7 @@
activity: Activity;
activityMessage: string;
onClose: () => void;
onRemoved: (activityId: number) => void;
onRemoved: (activity: Activity) => void;
onUpdated: (activityId: number, updatedActivity: Activity) => void;
}
@@ -85,7 +85,7 @@
if (!success) {
return;
}
onRemoved(activity.id);
onRemoved(activity);
onClose();
}
</script>
+41 -9
View File
@@ -27,25 +27,45 @@
/>
</svg>
{:else if i === "clock"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
>
<path
d="M256 48C141.13 48 48 141.13 48 256c0 114.69 93.32 208 208 208 114.86 0 208-93.14 208-208 0-114.69-93.31-208-208-208zm108 240H244a4 4 0 01-4-4V116a4 4 0 014-4h24a4 4 0 014 4v140h92a4 4 0 014 4v24a4 4 0 01-4 4z"
/>
</svg>
{:else if i === "play"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
><path
d="M133 440a35.37 35.37 0 01-17.5-4.67c-12-6.8-19.46-20-19.46-34.33V111c0-14.37 7.46-27.53 19.46-34.33a35.13 35.13 0 0135.77.45l247.85 148.36a36 36 0 010 61l-247.89 148.4A35.5 35.5 0 01133 440z"
/></svg
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
fill="currentColor"
>
<path
d="M133 440a35.37 35.37 0 01-17.5-4.67c-12-6.8-19.46-20-19.46-34.33V111c0-14.37 7.46-27.53 19.46-34.33a35.13 35.13 0 0135.77.45l247.85 148.36a36 36 0 010 61l-247.89 148.4A35.5 35.5 0 01133 440z"
/>
</svg>
{:else if i === "calendar"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
>
<path
d="M32 456a24 24 0 0024 24h400a24 24 0 0024-24V176H32zm80-238.86a9.14 9.14 0 019.14-9.14h109.72a9.14 9.14 0 019.14 9.14v109.72a9.14 9.14 0 01-9.14 9.14H121.14a9.14 9.14 0 01-9.14-9.14zM456 64h-55.92V32h-48v32H159.92V32h-48v32H56a23.8 23.8 0 00-24 23.77V144h448V87.77A23.8 23.8 0 00456 64z"
/>
</svg>
{:else if i === "thumb-down"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
><path
d="M39.94 178l144.16 6.12c4.61.36 23.9 1.22 23.9 25.88 0 23.8-19.16 25.33-24.14 25.88L39.94 242C27.27 241.87 16 227.56 16 210s11.27-31.87 23.94-32zm141.45 131.66L74.65 318C62 318 48 301.31 48 284.12v-.33c0-16.33 11.14-29.63 24.88-29.79l108.45 1.72C208 259 208 275.16 208 282.12c0 22.88-21.8 27.14-26.61 27.54zM90.15 32l89.37 8.93C204 41.86 208 58.18 208 68.4c0 18.39-13.41 24.6-26.67 24.6l-91 3C75.78 95.78 64 81.51 64 64s11.68-31.66 26.15-32zm-34.36 71.5l126.4 6.22c9.39.63 25.81 3 25.81 26.36 0 12-4.35 25.62-25 27.53L55.79 167.5C42.65 167.35 32 154 32 136.08s10.65-32.43 23.79-32.58z"
/><path
@@ -58,7 +78,12 @@
/></svg
>
{:else if i === "thumb-up"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
>
<path
d="M472.06 334l-144.16-6.13c-4.61-.36-23.9-1.21-23.9-25.87 0-23.81 19.16-25.33 24.14-25.88L472.06 270c12.67.13 23.94 14.43 23.94 32s-11.27 31.87-23.94 32zM330.61 202.33L437.35 194C450 194 464 210.68 464 227.88v.33c0 16.32-11.14 29.62-24.88 29.79l-108.45-1.73C304 253 304 236.83 304 229.88c0-22.88 21.8-27.15 26.61-27.55zM421.85 480l-89.37-8.93C308 470.14 304 453.82 304 443.59c0-18.38 13.41-24.6 26.67-24.6l91-3c14.54.23 26.32 14.5 26.32 32s-11.67 31.67-26.14 32.01zm34.36-71.5l-126.4-6.21c-9.39-.63-25.81-3-25.81-26.37 0-12 4.35-25.61 25-27.53l127.19-3.88c13.16.14 23.81 13.49 23.81 31.4s-10.65 32.43-23.79 32.58z"
/>
@@ -73,7 +98,12 @@
/>
</svg>
{:else if i === "pause"}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 512 512"
width={wh}
height={wh}
>
<path
d="M256 48C141.31 48 48 141.31 48 256s93.31 208 208 208 208-93.31 208-208S370.69 48 256 48zm-32 272a16 16 0 01-32 0V192a16 16 0 0132 0zm96 0a16 16 0 01-32 0V192a16 16 0 0132 0z"
/>
@@ -400,6 +430,7 @@
width={wh}
height={wh}
viewBox="0 0 512 512"
fill="currentColor"
>
<path
d="M394 480a16 16 0 01-9.39-3L256 383.76 127.39 477a16 16 0 01-24.55-18.08L153 310.35 23 221.2a16 16 0 019-29.2h160.38l48.4-148.95a16 16 0 0130.44 0l48.4 149H480a16 16 0 019.05 29.2L359 310.35l50.13 148.53A16 16 0 01394 480z"
@@ -608,6 +639,7 @@
viewBox="0 0 512 512"
>
<path
fill="currentColor"
d="M208 512a24.84 24.84 0 01-23.34-16l-39.84-103.6a16.06 16.06 0 00-9.19-9.19L32 343.34a25 25 0 010-46.68l103.6-39.84a16.06 16.06 0 009.19-9.19L184.66 144a25 25 0 0146.68 0l39.84 103.6a16.06 16.06 0 009.19 9.19l103 39.63a25.49 25.49 0 0116.63 24.1 24.82 24.82 0 01-16 22.82l-103.6 39.84a16.06 16.06 0 00-9.19 9.19L231.34 496A24.84 24.84 0 01208 512zm66.85-254.84zM88 176a14.67 14.67 0 01-13.69-9.4l-16.86-43.84a7.28 7.28 0 00-4.21-4.21L9.4 101.69a14.67 14.67 0 010-27.38l43.84-16.86a7.31 7.31 0 004.21-4.21L74.16 9.79A15 15 0 0186.23.11a14.67 14.67 0 0115.46 9.29l16.86 43.84a7.31 7.31 0 004.21 4.21l43.84 16.86a14.67 14.67 0 010 27.38l-43.84 16.86a7.28 7.28 0 00-4.21 4.21l-16.86 43.84A14.67 14.67 0 0188 176zM400 256a16 16 0 01-14.93-10.26l-22.84-59.37a8 8 0 00-4.6-4.6l-59.37-22.84a16 16 0 010-29.86l59.37-22.84a8 8 0 004.6-4.6l22.67-58.95a16.45 16.45 0 0113.17-10.57 16 16 0 0116.86 10.15l22.84 59.37a8 8 0 004.6 4.6l59.37 22.84a16 16 0 010 29.86l-59.37 22.84a8 8 0 00-4.6 4.6l-22.84 59.37A16 16 0 01400 256z"
/>
</svg>
+1
View File
@@ -69,6 +69,7 @@
min-width: 300px;
width: 100%;
max-height: calc(100% - 20px);
color: $text-color;
background-color: $bg-color;
border-radius: 10px;
padding: 15px 20px;
+1 -1
View File
@@ -12,7 +12,7 @@
.loader {
width: 16px;
height: 12px;
border: 2px solid #000;
border: 2px solid $text-color;
border-bottom-color: transparent;
border-radius: 50%;
display: inline-block;
+11
View File
@@ -78,10 +78,21 @@
gap: 10px;
width: 100%;
height: 100%;
container-type: inline-size;
button {
font-size: 10px;
padding: 5px 10px;
height: 58px;
}
/* 380px is how big the this container is usually,
but when it starts to shrink we want to button height
to be unset so that buttons become responsive. */
@container (width < 380px) {
button {
height: unset;
}
}
}
</style>
+31
View File
@@ -0,0 +1,31 @@
import type { Activity, Watched } from "@/types";
/**
* Activity removed hook for all content pages to use
* since the code will be the same.
*/
export function activityRemovedHook(w?: Watched, a?: Activity) {
if (!w || !w.activity || w.activity?.length == 0 || !a) {
console.warn("activityRemovedHook: Watched or activity not defined!", w, a);
return;
}
activityRemovedUpdateWatchedPlays(w, a.countAsPlay);
w.activity = w.activity.filter((ac) => ac.id !== a.id);
}
/**
* Update local watched state with new `plays` when removing activity.
*/
function activityRemovedUpdateWatchedPlays(
w: Watched,
removedCountedAsPlay: boolean,
) {
// Only if the removed actitivty counted as a play
// will we update the state.
if (!removedCountedAsPlay) {
return;
}
if (w.plays && w.plays > 0) {
w.plays--;
}
}
+6
View File
@@ -30,6 +30,12 @@
thoughts={watched?.thoughts}
onChange={onThoughtsChanged}
/>
{#if typeof watched.plays == "number" && watched.plays > 0}
<div>
{watched.plays}
{watched.plays > 1 ? "Plays" : "Play"}
</div>
{/if}
{/if}
</div>
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import type { TMDBContentCreditsCrew } from "@/types";
interface Props {
topCrew: TMDBContentCreditsCrew[];
}
let { topCrew }: Props = $props();
</script>
<div class="creators">
{#each topCrew as crew}
<div>
<a href={`/person/${crew.id}`}>{crew.name}</a>
<span>{crew.job}</span>
</div>
{/each}
</div>
<style lang="scss">
.creators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 35px;
margin: 10px 60px;
div {
display: flex;
flex-flow: column;
min-width: 150px;
a {
font-weight: bold;
}
}
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import tooltip from "../actions/tooltip";
import Icon from "../Icon.svelte";
import { removeWatched } from "../util/api";
import { notify } from "../util/notify";
import WatchedDeleteModal from "../watched/WatchedDeleteModal.svelte";
interface Props {
watchedId: number;
mediaName?: string;
onDelete: () => void;
}
let { watchedId, mediaName, onDelete }: Props = $props();
// If confirm delete modal should be shown, then this will be
// set to a callback.
let showConfirmDeleteModalCallback: (() => void) | undefined = $state();
function onDeleteClicked() {
showConfirmDeleteModalCallback = () => {
removeWatched(watchedId).then((removed) => {
if (removed) {
onDelete();
}
});
};
}
</script>
<button
class="delete-btn"
onclick={() => onDeleteClicked()}
use:tooltip={{ text: "Delete", pos: "bot" }}
>
<Icon i="trash" wh={19} />
</button>
{#if showConfirmDeleteModalCallback !== undefined}
<WatchedDeleteModal
{mediaName}
onClose={(confirmed) => {
if (!showConfirmDeleteModalCallback) {
notify({
type: "error",
text: "Somehow the deletion callback doesn't exist anymore so we couldn't delete! Please try again",
time: 5000,
});
return;
}
if (confirmed) {
showConfirmDeleteModalCallback();
}
showConfirmDeleteModalCallback = undefined;
}}
/>
{/if}
<style lang="scss">
.delete-btn {
&:hover {
color: $error;
}
}
</style>
+48 -7
View File
@@ -20,6 +20,7 @@
import ExtraDetails from "./ExtraDetails.svelte";
import { buildExtraDetails } from "./lib";
import { decode } from "blurhash";
import WatchedDeleteModal from "../watched/WatchedDeleteModal.svelte";
interface Props {
media: Media;
@@ -75,6 +76,13 @@
let containerEl: HTMLDivElement | undefined = $state();
let bhCanvas: HTMLCanvasElement | undefined = $state();
// If confirm delete modal should be shown, then this will be
// set to a callback.
let showConfirmDeleteModalCallback: (() => void) | undefined = $state();
// If the item was just deleted from watched list (via this poster.)
let justDeletedFromWatcheds = $state(false);
const meta:
| {
id: number | undefined;
@@ -136,6 +144,11 @@
media.releaseDate ? new Date(media.releaseDate).getFullYear() : undefined,
);
function updateWatchedVar(w: Watched | undefined) {
watched = w;
justDeletedFromWatcheds = typeof w === "undefined";
}
function handleStarClick(r: number) {
if (r == watched?.rating || !meta?.id) return;
updateWatched(watched, {
@@ -149,7 +162,7 @@
}
// If watched was just added, we need to assign
// it to our `watched` var to get the update.
watched = w;
updateWatchedVar(w);
});
}
@@ -162,11 +175,14 @@
});
return;
}
removeWatched(watched.id).then((removed) => {
if (removed) {
watched = undefined;
}
});
showConfirmDeleteModalCallback = () => {
if (!watched) return;
removeWatched(watched.id).then((removed) => {
if (removed) {
updateWatchedVar(undefined);
}
});
};
return;
}
if (type == watched?.status || !meta?.id) return;
@@ -181,7 +197,7 @@
}
// If watched was just added, we need to assign
// it to our `watched` var to get the update.
watched = w;
updateWatchedVar(w);
});
}
@@ -301,6 +317,7 @@
}}
onkeypress={() => console.log("on kpress")}
class={`${posterActive ? "active " : ""}${watched?.pinned ? "pinned " : ""}${hideIfNotOnList && !watched ? "hidden " : ""}`}
class:just-deleted={justDeletedFromWatcheds}
>
<div
class={`container${!poster || posterImgLoaded == -1 ? " details-shown" : ""}`}
@@ -381,6 +398,26 @@
</div>
</li>
{#if showConfirmDeleteModalCallback !== undefined}
<WatchedDeleteModal
mediaName={media.name}
onClose={(confirmed) => {
if (!showConfirmDeleteModalCallback) {
notify({
type: "error",
text: "Somehow the deletion callback doesn't exist anymore so we couldn't delete! Please try again",
time: 5000,
});
return;
}
if (confirmed) {
showConfirmDeleteModalCallback();
}
showConfirmDeleteModalCallback = undefined;
}}
/>
{/if}
<style lang="scss">
li.hidden {
display: none;
@@ -403,6 +440,10 @@
}
}
li.just-deleted:not(.active) {
filter: grayscale(0.8) blur(1px);
}
.container {
display: flex;
flex-flow: column;
+21 -15
View File
@@ -27,14 +27,11 @@ export const baseURL =
: "/api";
console.log("api: baseURL constructed:", baseURL);
interface UpdateWatchedSharedOptions {
status?: WatchedStatus;
rating?: number;
thoughts?: string;
pinned?: boolean;
}
interface UpdateWatchedOptions extends UpdateWatchedSharedOptions {
/**
* Options for our internal updateWatched func.
*/
export interface UpdateWatchedOptions
extends Omit<WatchedUpdateRequest, "removeThoughts"> {
/**
* TMDB ID.
*/
@@ -52,6 +49,7 @@ async function _updateWatched(
rating?: number,
thoughts?: string,
pinned?: boolean,
letCountAsPlay?: boolean,
) {
if (
!status &&
@@ -70,6 +68,9 @@ async function _updateWatched(
if (typeof thoughts !== "undefined") obj.thoughts = thoughts;
if (thoughts === "") obj.removeThoughts = true;
if (typeof pinned !== "undefined") obj.pinned = pinned;
if (typeof letCountAsPlay !== "undefined") {
obj.letCountAsPlay = letCountAsPlay;
}
const resp = await axios.put<WatchedUpdateResponse>(
`/watched/${wEntry.id}`,
obj,
@@ -79,21 +80,25 @@ async function _updateWatched(
if (typeof thoughts !== "undefined") wEntry.thoughts = thoughts;
if (typeof pinned !== "undefined") wEntry.pinned = pinned;
if (resp?.data?.newActivity && resp?.data?.newActivity?.id) {
if (wEntry.activity?.length > 0) {
if (wEntry.activity && wEntry.activity.length > 0) {
wEntry.activity.push(resp.data.newActivity);
} else {
wEntry.activity = [resp.data.newActivity];
}
// We want to update the updatedAt field too (so
// change is reflected when filtering modified at)
// We can piggy back from this data for now.
wEntry.updatedAt = resp.data.newActivity.createdAt;
// If new activity counts as play, increment plays for local state.
if (resp.data.newActivity.countAsPlay) {
if (wEntry.plays) {
wEntry.plays++;
} else {
wEntry.plays = 1;
}
}
}
}
/**
* Add or update watched show/movie.
* @param wEntry The watched entry (movie or tv only) we are updating.
* Add or update watched media.
* @param wEntry The watched entry we are updating.
* @param opts Update options.
* @returns Updated watched entry if request succeeded, otherwise will
* throw error after displaying updating the notification to "failed".
@@ -114,6 +119,7 @@ export async function updateWatched(
opts.rating,
opts.thoughts,
opts.pinned,
opts.letCountAsPlay,
);
notify({ id: nid, text: `Saved!`, type: "success" });
} catch (err) {
+24
View File
@@ -0,0 +1,24 @@
// A simple signal implementation.
// For example, we can use a Signal to allow waiting for a decision
// from the user in a Modal and then continue logic depending on the response.
export type SignalFireFunc<T> = (value: T) => void;
export interface Signal<T> {
promise: Promise<T>;
fire: SignalFireFunc<T>;
}
/**
* Create a new signal.
* Returns a new promise that we can await.
* The `fire` function resolves the promise.
*/
export function createSignal<T>(): Signal<T> {
let resolve: SignalFireFunc<T>;
const promise = new Promise<T>((r) => (resolve = r));
return {
promise,
fire: (data: T) => resolve(data),
};
}
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import Modal from "../Modal.svelte";
import type { Signal } from "../util/signal";
interface Props {
onDecision: Signal<boolean>;
}
let { onDecision }: Props = $props();
</script>
<Modal
title="Count as Whole Play"
desc="Do you want to increment the Plays for this media by one?"
maxWidth="450px"
>
<div class="button-row">
<button class="danger" onclick={() => onDecision.fire(false)}>
No, Don't Count
</button>
<button onclick={() => onDecision.fire(true)}>Yes, Count as Play</button>
</div>
</Modal>
<style lang="scss">
.button-row {
display: flex;
flex-flow: row;
justify-content: space-between;
margin-top: 10px;
button {
margin-top: auto;
width: max-content;
}
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<script lang="ts">
import Modal from "../Modal.svelte";
interface Props {
mediaName?: string;
/**
* If `confirmed=true` then the deletion request was confirmed.
*/
onClose: (confirmed: boolean) => void;
}
let { mediaName, onClose }: Props = $props();
</script>
<Modal
title="Remove From Your List?"
onClose={() => onClose(false)}
maxWidth="650px"
>
<div class="remove-ctr">
<p>
This will delete <b>{mediaName}</b> from your main list. Data will be lost.
</p>
<button class="confirm-delete-btn" onclick={() => onClose(true)}>
Remove from my main list.
</button>
</div>
</Modal>
<style lang="scss">
div.remove-ctr {
display: flex;
flex-flow: column;
gap: 10px;
button.confirm-delete-btn {
width: max-content;
margin-left: auto;
&:hover {
color: $error;
}
}
}
</style>
+43 -38
View File
@@ -229,10 +229,12 @@
<nav bind:this={navEl}>
<div class="wrapper">
<a href="/">
<span class="large">Watcharr</span>
<span class="small">W</span>
</a>
<div class="left-side">
<a href="/">
<span class="large">Watcharr</span>
<span class="small">W</span>
</a>
</div>
<div class="search">
<input
bind:this={mainSearchEl}
@@ -403,7 +405,7 @@
justify-content: space-between;
align-items: center;
a,
.left-side,
.btns {
/* This makes the logo on left and icons on right the same
width, ensuring the main search bar can stay truly centered
@@ -435,42 +437,45 @@
}
}
a {
text-decoration: none;
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 35px;
transition:
-webkit-text-stroke 150ms ease,
color 150ms ease,
font-weight 150ms ease;
.left-side {
a {
display: inline-flex;
text-decoration: none;
font-family:
"Shrikhand",
system-ui,
-apple-system,
BlinkMacSystemFont;
font-size: 35px;
transition:
-webkit-text-stroke 150ms ease,
color 150ms ease,
font-weight 150ms ease;
&:hover,
&:focus-visible {
color: $bg-color;
-webkit-text-stroke: 3px $text-color;
font-weight: bold;
}
span.large {
display: block;
width: 185.2px;
}
span.small {
display: none;
width: 40px;
}
@media screen and (max-width: 620px) {
span.large {
display: none;
&:hover,
&:focus-visible {
color: $bg-color;
-webkit-text-stroke: 3px $text-color;
font-weight: bold;
}
span.small {
span.large {
display: block;
width: 185.2px;
}
span.small {
display: none;
width: 40px;
}
@media screen and (max-width: 620px) {
span.large {
display: none;
}
span.small {
display: block;
}
}
}
}
+15 -24
View File
@@ -18,6 +18,8 @@
import PosterImage from "@/lib/content/PosterImage.svelte";
import Poster from "@/lib/poster/Poster.svelte";
import ExpandableText from "@/lib/content/ExpandableText.svelte";
import WatchedDeleteBtn from "@/lib/content/WatchedDeleteBtn.svelte";
import { activityRemovedHook } from "@/lib/activity.js";
let { data } = $props();
@@ -78,16 +80,6 @@
return false;
}
}
async function deleteWatched() {
if (game?.watched) {
if (await removeWatched(game.watched.id)) {
game.watched = undefined;
}
return;
}
console.error("deleteWatched: no wlistItem.. can't delete");
}
</script>
<svelte:head>
@@ -173,13 +165,15 @@
>
<Icon i={game.watched?.pinned ? "unpin" : "pin"} wh={19} />
</button>
<button
class="delete-btn"
onclick={() => deleteWatched()}
use:tooltip={{ text: "Delete", pos: "bot" }}
>
<Icon i="trash" wh={19} />
</button>
<WatchedDeleteBtn
watchedId={game.watched.id}
mediaName={game.name}
onDelete={() => {
if (game) {
game.watched = undefined;
}
}}
/>
</div>
{/if}
</div>
@@ -220,7 +214,10 @@
{/if}
{#if game.watched}
<Activity bind:activity={game.watched.activity} />
<Activity
activity={game.watched.activity}
onRemoved={(a) => activityRemovedHook(game?.watched, a)}
/>
{/if}
</div>
</div>
@@ -277,12 +274,6 @@
margin-left: auto;
}
}
.delete-btn {
&:hover {
color: $error;
}
}
}
}
}
+42 -16
View File
@@ -298,33 +298,59 @@
const toImport: ImportedList[] = [];
const fileText = await readFile(new FileReader(), file);
const jsonData = JSON.parse(fileText) as any[];
let invalidStructureErrorOccurred = false;
let processedGame = false;
for (const v of jsonData) {
if (!v.content || !v.content.title) {
notify({
type: "error",
text: "Item in export has no content or a missing title! Look in console for more details.",
});
console.error(
"Can't add export item to import table! It has no content or a missing content.title! Item:",
v,
);
continue;
}
const t: ImportedList = {
tmdbId: v.content.tmdbId,
name: v.content.title,
year: new Date(v.content.release_date)?.getFullYear(),
type: v.content.type,
rating: v.rating,
status: v.status,
thoughts: v.thoughts,
// datesWatched: [new Date(v.createdAt)], // Shouldn't need this, all activity will be imported, including ADDED_WATCHED activity
activity: v.activity,
watchedEpisodes: v.watchedEpisodes,
watchedSeasons: v.watchedSeasons,
};
if (v.content) {
t.tmdbId = v.content.tmdbId;
t.name = v.content.title;
t.year = new Date(v.content.release_date)?.getFullYear();
t.type = v.content.type;
} else if (v.game) {
t.igdbId = v.game.igdbId;
t.name = v.game.name;
t.year = new Date(v.game.releaseDate)?.getFullYear();
t.type = "game";
processedGame = true;
} else {
console.error("processWatcharrFile: Went over invalid item.", v);
if (!invalidStructureErrorOccurred) {
notify({
type: "error",
text: "Item(s) in export has an invalid structure! Look in console for more details.",
});
// So we don't spam it...
invalidStructureErrorOccurred = true;
}
continue;
}
toImport.push(t);
}
if (processedGame && !store.serverFeatures?.games) {
// We know the server hasnt told us it supports games yet
// for this session, so alert the user of this before they start
// importing.
notify({
type: "error",
text:
"It looks like this server doesn't support 'Games'! " +
"The server should be configured properly before " +
"proceeding otherwise none of your games will be imported!",
time: Infinity,
});
// We still allow the user to continue (BECAUSE EIN CUSTOMER ISH ALWAYS REIGHT!!!!
// or maybe they don't care about games, or maybe they configured
// it in another tab and know for a fact that it should work.)
}
console.log("toImport:", toImport);
store.importedList = {
data: JSON.stringify(toImport),
+38 -5
View File
@@ -45,6 +45,15 @@
cancelled = true;
});
// This isn't reactive to avoid bugs (hopefully this isn't a bug)
let dropDownSupportedTypes = (() => {
let t = ["movie", "tv"];
if (store.serverFeatures?.games) {
t.push("game");
}
return t;
})();
// Set when current item being imported gets an IMPORT_MULTI
// response, which then shows the modal for user to pick correct item.
let importMultiItem: ImportedListItemMultiProblem | undefined = $state();
@@ -75,7 +84,7 @@
const year = el.match(yearRegex);
if (year && year.length > 0) {
l.year = Number(year[0].replaceAll(/\(|\)/g, ""));
l.name = l.name.replace(yearRegex, "").trim();
l.name = l.name?.replace(yearRegex, "").trim();
}
rList.push(l);
}
@@ -328,7 +337,10 @@
try {
const startDateNode = animeNode.querySelector("my_start_date");
const finishDateNode = animeNode.querySelector("my_finish_date");
if (startDateNode?.textContent) {
if (
startDateNode?.textContent &&
startDateNode?.textContent != "0000-00-00"
) {
// For start date, we can simply add the activity manually.
l.activity = [
// We don't need all the data when importing activity.
@@ -340,7 +352,10 @@
},
] as any[];
}
if (finishDateNode?.textContent) {
if (
finishDateNode?.textContent &&
finishDateNode?.textContent != "0000-00-00"
) {
l.datesWatched = [new Date(finishDateNode.textContent)];
}
} catch (err) {
@@ -631,7 +646,7 @@
</td>
<td class="type">
<DropDown
options={["movie", "tv"]}
options={dropDownSupportedTypes}
bind:active={l.type}
placeholder="Type"
blendIn={true}
@@ -743,9 +758,27 @@
const item = rList.find(
(i) => i.name === importMultiItem?.original.name,
);
console.log(
"MultipleResultsFound: Poster clicked. Item in rList:",
item,
);
if (item) {
item.tmdbId = r.ids.tmdb;
// We found the item in our import list, update it
// to match the selected choice and do the import with it.
item.type = getContentTypeFromMedia(r);
if (item.type === "game") {
item.igdbId = r.ids.igdb;
} else if (item.type === "movie" || item.type === "tv") {
item.tmdbId = r.ids.tmdb;
} else {
item.state = ImportResponseType.IMPORT_FAILED;
notify({
type: "error",
text: "Can't import selected result because it has an unsupported type associated with it!",
time: 10000,
});
return;
}
try {
await doImport(item);
importMultiItem?.callback(undefined);
+17 -50
View File
@@ -32,6 +32,9 @@
import ViewTrailerButton from "@/lib/content/ViewTrailerButton.svelte";
import PosterImage from "@/lib/content/PosterImage.svelte";
import ExpandableText from "@/lib/content/ExpandableText.svelte";
import WatchedDeleteBtn from "@/lib/content/WatchedDeleteBtn.svelte";
import TopCrewList from "@/lib/content/TopCrewList.svelte";
import { activityRemovedHook } from "@/lib/activity.js";
let { data } = $props();
@@ -112,16 +115,6 @@
return false;
}
}
async function deleteWatched() {
if (movie?.watched) {
if (await removeWatched(movie.watched.id)) {
movie.watched = undefined;
}
return;
}
console.error("deleteWatched: no wlistItem.. can't delete");
}
</script>
<svelte:head>
@@ -214,13 +207,15 @@
>
<Icon i={movie.watched?.pinned ? "unpin" : "pin"} wh={19} />
</button>
<button
class="delete-btn"
onclick={() => deleteWatched()}
use:tooltip={{ text: "Delete", pos: "bot" }}
>
<Icon i="trash" wh={19} />
</button>
<WatchedDeleteBtn
watchedId={movie.watched.id}
mediaName={movie.name}
onDelete={() => {
if (movie) {
movie.watched = undefined;
}
}}
/>
</div>
{/if}
</div>
@@ -269,14 +264,7 @@
{:then credits}
<!-- TODO make this nicer -->
{#if credits.topCrew?.length > 0}
<div class="creators">
{#each credits.topCrew as crew}
<div>
<span>{crew.name}</span>
<span>{crew.job}</span>
</div>
{/each}
</div>
<TopCrewList topCrew={credits.topCrew} />
{/if}
{#if credits.cast?.length > 0}
@@ -301,7 +289,10 @@
{/if}
{#if movie.watched}
<Activity bind:activity={movie.watched.activity} />
<Activity
activity={movie.watched.activity}
onRemoved={(a) => activityRemovedHook(movie?.watched, a)}
/>
{/if}
</div>
</div>
@@ -359,12 +350,6 @@
margin-left: auto;
}
}
.delete-btn {
&:hover {
color: $error;
}
}
}
}
}
@@ -383,22 +368,4 @@
padding: 20px;
}
}
.creators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 35px;
margin: 10px 60px;
div {
display: flex;
flex-flow: column;
min-width: 150px;
span:first-child {
font-weight: bold;
}
}
}
</style>
@@ -47,8 +47,8 @@
instance.
</p>
<p>
If you are migrating to another server you own, it's best to migrate take
your existing database with you.
If you are migrating to another server that you own, it's best to take your
existing database with you.
</p>
<p>
<b>Warning</b>: This is not a backup feature. Backups should be done on the
+42 -52
View File
@@ -13,6 +13,7 @@
contentExistsOnJellyfin,
removeWatched,
updateWatched,
type UpdateWatchedOptions,
} from "@/lib/util/api";
import { getTopCrew } from "@/lib/util/helpers.js";
import { store } from "@/store.svelte.js";
@@ -33,6 +34,11 @@
import ViewTrailerButton from "@/lib/content/ViewTrailerButton.svelte";
import PosterImage from "@/lib/content/PosterImage.svelte";
import ExpandableText from "@/lib/content/ExpandableText.svelte";
import WatchedDeleteBtn from "@/lib/content/WatchedDeleteBtn.svelte";
import TopCrewList from "@/lib/content/TopCrewList.svelte";
import { activityRemovedHook } from "@/lib/activity.js";
import CountAsPlayModal from "@/lib/watched/CountAsPlayModal.svelte";
import { createSignal, type Signal } from "@/lib/util/signal.js";
let { data } = $props();
@@ -41,6 +47,7 @@
let arrRequestButtonComp: ArrRequestButton | undefined = $state();
let show: Media | undefined = $state();
let pageError: Error | undefined = $state();
let countAsPlayModalSignal: Signal<boolean> | undefined = $state();
$effect(() => {
(async () => {
@@ -100,29 +107,34 @@
console.error("contentChanged: no show");
return false;
}
show.watched = await updateWatched(show.watched, {
const reqOpts: UpdateWatchedOptions = {
contentId: data.tvId,
contentType: "tv",
status: newStatus,
rating: newRating,
thoughts: newThoughts,
pinned: pinned,
});
};
// Series differ from other media in that people are likely to go
// back out of the 'FINISHED' state when a new season releases
// while they watch it, then go back to 'FINISHED' after only
// watching the new season. Because of that use case, for series,
// we will ask the user if any 'FINISHED' statuses set when plays>1
// should count as a play..
if (show.watched?.plays && newStatus == "FINISHED") {
countAsPlayModalSignal = createSignal<boolean>();
const allow = await countAsPlayModalSignal.promise;
countAsPlayModalSignal = undefined;
if (!allow) {
reqOpts.letCountAsPlay = false;
}
}
show.watched = await updateWatched(show.watched, reqOpts);
return true;
} catch {
return false;
}
}
async function deleteWatched() {
if (show?.watched) {
if (await removeWatched(show.watched.id)) {
show.watched = undefined;
}
return;
}
console.error("deleteWatched: no wlistItem.. can't delete");
}
</script>
<svelte:head>
@@ -213,13 +225,15 @@
>
<Icon i={show.watched?.pinned ? "unpin" : "pin"} wh={19} />
</button>
<button
class="delete-btn"
onclick={() => deleteWatched()}
use:tooltip={{ text: "Delete", pos: "bot" }}
>
<Icon i="trash" wh={19} />
</button>
<WatchedDeleteBtn
watchedId={show.watched.id}
mediaName={show.name}
onDelete={() => {
if (show) {
show.watched = undefined;
}
}}
/>
</div>
{/if}
</div>
@@ -246,6 +260,10 @@
/>
</div>
{#if countAsPlayModalSignal}
<CountAsPlayModal onDecision={countAsPlayModalSignal} />
{/if}
{#if requestModalShown}
<RequestShow
content={show}
@@ -267,14 +285,7 @@
<Spinner />
{:then credits}
{#if credits.topCrew?.length > 0}
<div class="creators">
{#each credits.topCrew as crew}
<div>
<span>{crew.name}</span>
<span>{crew.job}</span>
</div>
{/each}
</div>
<TopCrewList topCrew={credits.topCrew} />
{/if}
{#if credits.cast?.length > 0}
@@ -299,7 +310,10 @@
{/if}
{#if show.watched}
<Activity bind:activity={show.watched.activity} />
<Activity
activity={show.watched.activity}
onRemoved={(a) => activityRemovedHook(show?.watched, a)}
/>
{/if}
{#if data?.tvId && show.seasons}
@@ -371,12 +385,6 @@
margin-left: auto;
}
}
.delete-btn {
&:hover {
color: $error;
}
}
}
}
}
@@ -395,22 +403,4 @@
padding: 20px;
}
}
.creators {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 35px;
margin: 10px 60px;
div {
display: flex;
flex-flow: column;
min-width: 150px;
span:first-child {
font-weight: bold;
}
}
}
</style>
+1 -1
View File
@@ -36,7 +36,7 @@
local("Shrikhand-Regular"),
url("/Shrikhand-Regular.woff2") format("woff2");
font-weight: normal;
font-style: italic;
font-style: normal;
font-display: swap;
}
+9 -4
View File
@@ -114,6 +114,7 @@ export interface Activity extends dbModel {
type: string;
data: string;
customDate: string;
countAsPlay: boolean;
}
export interface WatchedSeason {
@@ -151,6 +152,7 @@ export interface Watched {
watchedEpisodes?: WatchedEpisode[];
tags?: Tag[];
lastViewedSeason?: number;
plays?: number;
// 'Watching Season/Ep' Extra detail.
watchingSeason?: string;
@@ -172,6 +174,7 @@ export interface WatchedUpdateRequest {
thoughts?: string;
removeThoughts?: boolean;
pinned?: boolean;
letCountAsPlay?: boolean;
}
export interface WatchedUpdateResponse {
@@ -538,10 +541,13 @@ export interface ImportResponse {
export interface ImportedList {
tmdbId?: number;
name: string;
imdbId?: string;
igdbId?: number;
name?: string;
year?: number;
type?: ContentType;
state?: string;
type?: "movie" | "tv" | "tv_episode" | "game";
state?: ImportResponseType;
rating?: number;
ratingCustomDate?: Date;
status?: WatchedStatus;
@@ -551,7 +557,6 @@ export interface ImportedList {
watchedEpisodes?: WatchedEpisode[];
watchedSeasons?: WatchedSeason[];
tags?: TagAddRequest[];
imdbId?: string;
}
export interface Filters {