Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb2bd61f4a | |||
| 3461a4ec74 | |||
| 475c9b6ab4 | |||
| 325666c30d | |||
| 92816b02a0 | |||
| 363c648964 | |||
| 2f929e99d5 | |||
| f14be89680 | |||
| ecd027f603 | |||
| f80f237143 | |||
| dbfc354353 | |||
| 28a6d85672 | |||
| b861cfcec3 | |||
| 08e0021535 | |||
| b3bf111f82 | |||
| 23ed5ffca8 | |||
| cc9f246c9a | |||
| 75d75248e1 | |||
| c34a0878b2 | |||
| 681212566b | |||
| df37de00e1 | |||
| fc900d8e93 | |||
| 57ce143ebe | |||
| 3eb2810a31 | |||
| b8598ba404 | |||
| 579428d86b | |||
| 4da8730b6f | |||
| e980a1f70e | |||
| f73105e9c7 | |||
| 755b08b746 | |||
| 5e412f67b9 | |||
| 4ee38a2625 | |||
| dc6ead0fb3 |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 711 KiB |
|
After Width: | Height: | Size: 291 KiB |
@@ -0,0 +1,5 @@
|
||||
## Changelog Data
|
||||
|
||||
Changelog images, etc go in this folder.
|
||||
|
||||
[View the real changelog for Watcharr.](../CHANGELOG.md)
|
||||
@@ -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).
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2-dev1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "watcharr",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2-dev1",
|
||||
"license": "GPL-3.0-only",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "watcharr",
|
||||
"license": "GPL-3.0-only",
|
||||
"version": "3.0.1",
|
||||
"version": "3.0.2-dev1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.0.1
|
||||
3.0.2-dev1
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -54,4 +54,18 @@ type Activity struct {
|
||||
Data string `json:"data" gorm:"not null"`
|
||||
// Custom date for the activity, that the user can define.
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
// Count this Activity as a Play?
|
||||
// Currently this was the best way I could see forward for implementing
|
||||
// counting plays of media that doesn't involve inefficient querying of
|
||||
// the actitivties table (or a whole new table, which would create extra
|
||||
// complexities itself, ie, plays/activity showing different records).
|
||||
// We write to this field when creating the activity to count is as a play
|
||||
// or not (ie when we create STATUS CHANGE activities with status of
|
||||
// FINISHED, imports, etc).
|
||||
// We won't support the user (or the system) modifying this value after
|
||||
// creation; if the user wants to delete a 'Play', they should delete the
|
||||
// activity.
|
||||
// Indexed (check migrations) to make search faster, since we frequently
|
||||
// do it over the whole table for watched sorting at the moment.
|
||||
CountAsPlay bool `json:"countAsPlay"`
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ const (
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
func (r WatchedStatus) IsValid() bool {
|
||||
switch r {
|
||||
case FINISHED,
|
||||
WATCHING,
|
||||
PLANNED,
|
||||
HOLD,
|
||||
DROPPED:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type Watched struct {
|
||||
dbmodel.GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
|
||||
@@ -8,7 +8,7 @@ import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
// since episodes can be removed and re-added. For this reason we store season and episodes nums instead
|
||||
// of just the episode id.
|
||||
type WatchedEpisode struct {
|
||||
dbmodel.GormModel
|
||||
dbmodel.GormModelNoDel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
|
||||
@@ -4,7 +4,7 @@ import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
|
||||
// UniqueIndex applied between WatchedID and SeasonNumber to avoid duplicates incase logic fails.
|
||||
type WatchedSeason struct {
|
||||
dbmodel.GormModel
|
||||
dbmodel.GormModelNoDel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package migrate
|
||||
|
||||
import "time"
|
||||
|
||||
// Record of all our applied migrations.
|
||||
type MigrationRecord struct {
|
||||
// Migration ID
|
||||
ID string `gorm:"primarykey"`
|
||||
// When migration was applied on this db.
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ type WatchedDto struct {
|
||||
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes,omitempty"`
|
||||
Tags []entity.Tag `json:"tags,omitempty"`
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
// Amount of plays this media has, calculated from activity.
|
||||
Plays int `json:"plays,omitempty"`
|
||||
}
|
||||
|
||||
// New dto with base properties that we have for all WatchedDtos.
|
||||
@@ -137,6 +139,7 @@ func NewWatchedDtoForContentPage(w *entity.Watched) WatchedDto {
|
||||
dto.WatchedEpisodes = w.WatchedEpisodes
|
||||
dto.Tags = w.Tags
|
||||
dto.LastViewedSeason = w.LastViewedSeason
|
||||
dto.Plays = getPlaysFromActivity(w.Activity)
|
||||
|
||||
return dto
|
||||
}
|
||||
@@ -192,11 +195,35 @@ type WatchedAddRequest struct {
|
||||
|
||||
// Update watched entry request
|
||||
type WatchedUpdateRequest struct {
|
||||
Status entity.WatchedStatus `json:"status" binding:"required_without_all=Rating Thoughts RemoveThoughts Pinned"`
|
||||
Rating float64 `json:"rating" binding:"max=10,required_without_all=Status Thoughts RemoveThoughts Pinned"`
|
||||
Thoughts string `json:"thoughts" binding:"required_without_all=Status Rating RemoveThoughts Pinned"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating"`
|
||||
Thoughts string `json:"thoughts" `
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
Pinned *bool `json:"pinned" `
|
||||
// Allow the added activity count as play?
|
||||
// If the activity was going to count, this can stop it.
|
||||
LetCountAsPlay *bool `json:"letCountAsPlay"`
|
||||
}
|
||||
|
||||
// If the struct is valid for the Update Request.
|
||||
// - has atleast one property set.
|
||||
// - If rating is set, can't be out of bounds.
|
||||
func (w WatchedUpdateRequest) Valid() error {
|
||||
if w.Status == "" &&
|
||||
w.Rating == 0 &&
|
||||
(w.Thoughts == "" && !w.RemoveThoughts) &&
|
||||
w.Pinned == nil &&
|
||||
w.LetCountAsPlay == nil {
|
||||
// No properties are set, so this struct is not valid.
|
||||
return errors.New("no properties provided")
|
||||
}
|
||||
if w.Status != "" && !w.Status.IsValid() {
|
||||
return errors.New("status is not set to a supported status")
|
||||
}
|
||||
if w.Rating < 0 || w.Rating > 10 {
|
||||
return errors.New("rating can only be a value from 0-10")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update response.
|
||||
|
||||
@@ -19,31 +19,61 @@ func NewService(db *gorm.DB) *Service {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) GetActivity(userId uint, watchedId uint) ([]entity.Activity, error) {
|
||||
func (s *Service) GetActivity(
|
||||
userId uint,
|
||||
watchedId uint,
|
||||
) ([]entity.Activity, error) {
|
||||
activity := new([]entity.Activity)
|
||||
res := s.db.Model(&entity.Activity{}).Where("user_id = ? AND watched_id = ?", userId, watchedId).Find(&activity)
|
||||
res := s.db.Model(&entity.Activity{}).
|
||||
Where("user_id = ? AND watched_id = ?", userId, watchedId).
|
||||
Find(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed getting activity from database", "error", res.Error.Error())
|
||||
slog.Error("Failed getting activity from database",
|
||||
"error", res.Error.Error())
|
||||
return []entity.Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddActivity(userId uint, ar domain.ActivityAddRequest) (entity.Activity, error) {
|
||||
// NOTE: Currently this function doesn't verify if `userId` owns the referenced
|
||||
// watched item at `ar.WatchedID`. If we ever need this function to work from an
|
||||
// "AddActivity" endpoint on the API, we should create another func that has
|
||||
// that validation, since this func is only for internal operations!
|
||||
// AddActivity: Only for internal use.
|
||||
func (s *Service) AddActivity(
|
||||
userId uint,
|
||||
ar domain.ActivityAddProps,
|
||||
// If this activity counts as a play.
|
||||
countAsPlay bool,
|
||||
) (entity.Activity, error) {
|
||||
if ar.WatchedID == 0 {
|
||||
return entity.Activity{}, errors.New("watchedId must be set to add an activity")
|
||||
return entity.Activity{},
|
||||
errors.New("watchedId must be set to add an activity")
|
||||
}
|
||||
activity := entity.Activity{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
Type: ar.Type,
|
||||
Data: ar.Data,
|
||||
CustomDate: ar.CustomDate,
|
||||
CountAsPlay: countAsPlay,
|
||||
}
|
||||
activity := entity.Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data, CustomDate: ar.CustomDate}
|
||||
res := s.db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding activity to database", "error", res.Error.Error())
|
||||
return entity.Activity{}, errors.New("failed adding new activity to database")
|
||||
slog.Error("Error adding activity to database",
|
||||
"error", res.Error.Error())
|
||||
return entity.Activity{},
|
||||
errors.New("failed adding new activity to database")
|
||||
}
|
||||
slog.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateActivity(userId uint, id uint, activityUpdateRequest domain.ActivityUpdateRequest) error {
|
||||
func (s *Service) UpdateActivity(
|
||||
userId uint,
|
||||
id uint,
|
||||
activityUpdateRequest domain.ActivityUpdateRequest,
|
||||
) error {
|
||||
if id == 0 {
|
||||
return errors.New("id must be set to update an activity")
|
||||
}
|
||||
@@ -55,11 +85,12 @@ func (s *Service) UpdateActivity(userId uint, id uint, activityUpdateRequest dom
|
||||
Where("user_id = ? AND id = ?", userId, id).
|
||||
Update("custom_date", activityUpdateRequest.CustomDate)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating activity in database", "error", res.Error.Error())
|
||||
slog.Error("Error updating activity in database",
|
||||
"error", res.Error.Error())
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were updated. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
slog.Error("No activities were updated.")
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
slog.Debug("Updating activity", "updated_activity", id)
|
||||
@@ -72,11 +103,12 @@ func (s *Service) DeleteActivity(userId uint, id uint) error {
|
||||
}
|
||||
res := s.db.Where("user_id = ?", userId).Delete(&entity.Activity{}, id)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error deleting activity in database", "error", res.Error.Error())
|
||||
slog.Error("Error deleting activity in database",
|
||||
"error", res.Error.Error())
|
||||
return errors.New("failed deleting activity in database")
|
||||
}
|
||||
if res.RowsAffected < 1 {
|
||||
slog.Error("No activities were deleted. This may be because the activity doesn't exist or is not owned by the calling user.")
|
||||
slog.Error("No activities were deleted.")
|
||||
return errors.New("failed deleting activity from database")
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -27,7 +27,6 @@ func (r *Router) AddRoutes() {
|
||||
activity := r.br.Router.Group("/activity").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
activity.GET(":watchedId", r.GetActivity)
|
||||
activity.POST("", r.AddActivity)
|
||||
activity.PUT(":id", r.UpdateActivity)
|
||||
activity.DELETE(":id", r.DeleteActivity)
|
||||
}
|
||||
@@ -47,22 +46,6 @@ func (r *Router) GetActivity(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
func (r *Router) AddActivity(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar domain.ActivityAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.service.AddActivity(userId, ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) UpdateActivity(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
|
||||
@@ -170,10 +170,12 @@ func (s *Service) SuccessfulImport(
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrWatchedExists) {
|
||||
slog.Error("successfulImport: Must already be on watch list", "error", err)
|
||||
slog.Error("successfulImport: Must already be on watch list",
|
||||
"error", err)
|
||||
return domain.ImportResponse{Type: domain.IMPORT_EXISTS}
|
||||
}
|
||||
slog.Error("successfulImport: Failed to add content as watched", "error", err)
|
||||
slog.Error("successfulImport: Failed to add content as watched",
|
||||
"error", err)
|
||||
return domain.ImportResponse{Type: domain.IMPORT_FAILED}
|
||||
}
|
||||
// Add activity of the original time the show was added to the users
|
||||
@@ -185,21 +187,58 @@ func (s *Service) SuccessfulImport(
|
||||
"rating": ar.Rating,
|
||||
"linkedActivity": w.Activity[0].ID,
|
||||
})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_RATING,
|
||||
Data: string(activityJson),
|
||||
CustomDate: ar.RatingCustomDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_RATING,
|
||||
Data: strconv.Itoa(int(ar.Rating)),
|
||||
CustomDate: ar.RatingCustomDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
}
|
||||
// Add all dates watched as activity, if any
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, v := range ar.DatesWatched {
|
||||
for i, v := range ar.DatesWatched {
|
||||
countAsPlay := true
|
||||
if i == 0 && ar.Status == entity.FINISHED {
|
||||
// If the watched status we are importing is of FINISHED
|
||||
// then the first DatesWatched must not count as a play,
|
||||
// since the import activity (set in AddWatched) will already.
|
||||
// Any subsequent DatesWatched should count as a play though.
|
||||
countAsPlay = false
|
||||
slog.Info("successfulImport: Set countAsPlay=false for first" +
|
||||
"DatesWatched to avoid duplicate play count with AddWatched activity.")
|
||||
}
|
||||
customDate := v
|
||||
addedActivity, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
addedActivity, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED,
|
||||
CustomDate: &customDate,
|
||||
},
|
||||
countAsPlay,
|
||||
)
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.", "date", v, "error", err)
|
||||
slog.Error("successfulImport: Failed to add dateswatched activity.",
|
||||
"date", v, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -212,11 +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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -232,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
|
||||
@@ -251,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
|
||||
@@ -266,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 {
|
||||
@@ -276,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
|
||||
@@ -285,7 +338,8 @@ func (s *Service) SuccessfulImport(
|
||||
// Associate the watched entry with the tag
|
||||
err = watched.AddWatchedToTag(s.db, userId, t.ID, w.ID)
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.",
|
||||
"error", err)
|
||||
continue
|
||||
}
|
||||
w.Tags = append(w.Tags, t)
|
||||
|
||||
@@ -257,7 +257,7 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: entity.PLANNED,
|
||||
GormModel: dbmodel.GormModel{
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
})
|
||||
@@ -283,7 +283,7 @@ func (t *TraktService) startTraktImport(jobId string, userId uint, req TraktImpo
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: entity.PLANNED,
|
||||
GormModel: dbmodel.GormModel{
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
}}
|
||||
@@ -393,7 +393,7 @@ func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[stri
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: entity.FINISHED,
|
||||
// Rating: ,
|
||||
GormModel: dbmodel.GormModel{
|
||||
GormModelNoDel: dbmodel.GormModelNoDel{
|
||||
CreatedAt: v.WatchedAt,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -175,7 +175,15 @@ func (s *SyncService) startJellyfinSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_JF activity
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
CustomDate: &v.UserData.LastPlayedDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "movie_name", v.Name,
|
||||
"movie_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
@@ -280,10 +288,22 @@ func (s *SyncService) startJellyfinSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED activity (only if no err above, show also must not have already been on our list)
|
||||
if !v.UserData.LastPlayedDate.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_JF,
|
||||
CustomDate: &v.UserData.LastPlayedDate,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.", "series_name", v.Name,
|
||||
"series_ids", v.ProviderIds, "user_id", userId, "date", v.UserData.LastPlayedDate, "error", err)
|
||||
slog.Error("jellyfinSyncWatched: Failed to add dateswatched activity.",
|
||||
"series_name", v.Name,
|
||||
"series_ids", v.ProviderIds,
|
||||
"user_id", userId,
|
||||
"date", v.UserData.LastPlayedDate,
|
||||
"error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,11 +150,15 @@ func (s *SyncService) startPlexSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", movie.Title,
|
||||
"movie_id", movie.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", movie.LastViewedAt, "error", err)
|
||||
@@ -236,11 +240,15 @@ func (s *SyncService) startPlexSync(
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
_, err := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
},
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to add dateswatched activity.", "movie_name", show.Title,
|
||||
"movie_id", show.GUID, "user_id", userId, "date", lastViewedAt, "unparsed_date", show.LastViewedAt, "error", err)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -120,14 +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, domain.WatchedAddExtraProps{
|
||||
ActivityType: entity.ADDED_WATCHED,
|
||||
})
|
||||
newWatched, err := r.s.AddWatched(
|
||||
userId,
|
||||
ar,
|
||||
domain.WatchedAddExtraProps{
|
||||
ActivityType: entity.ADDED_WATCHED,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
dto := domain.NewWatchedDtoForContentPage(&newWatched)
|
||||
c.JSON(http.StatusOK, dto)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
|
||||
@@ -46,7 +46,11 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
slog.Debug("Adding watched season item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w entity.Watched
|
||||
if resp := s.db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedSeasons").Find(&w); resp.Error != nil {
|
||||
if resp := s.db.
|
||||
Where("id = ? AND user_id = ?", ar.WatchedID, userId).
|
||||
Preload("Content").
|
||||
Preload("WatchedSeasons").
|
||||
Find(&w); resp.Error != nil {
|
||||
slog.Error("Failed when adding a watched season", "error", "failed to get watched item from db")
|
||||
return WatchedSeasonAddResponse{}, errors.New("failed when retrieving watched item")
|
||||
}
|
||||
@@ -96,11 +100,27 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.SEASON_STATUS_CHANGED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_RATING_CHANGED, Data: string(json)})
|
||||
addedActivity, _ = s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: w.ID,
|
||||
Type: entity.SEASON_RATING_CHANGED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -113,14 +133,14 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
}
|
||||
}
|
||||
json, _ := json.Marshal(actData)
|
||||
act := domain.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_ADDED, Data: string(json)}
|
||||
act := domain.ActivityAddProps{WatchedID: w.ID, Type: entity.SEASON_ADDED, Data: string(json)}
|
||||
if ar.AddActivity != "" {
|
||||
act.Type = ar.AddActivity
|
||||
}
|
||||
if !ar.AddActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.AddActivityDate
|
||||
}
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, act)
|
||||
addedActivity, _ = s.activityProvider.AddActivity(userId, act, false)
|
||||
}
|
||||
return WatchedSeasonAddResponse{
|
||||
WatchedSeasons: w.WatchedSeasons,
|
||||
@@ -132,7 +152,12 @@ func (s *Service) AddWatchedSeason(userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
func (s *Service) RmWatchedSeason(userId uint, seasonId uint) (entity.Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "season_id", seasonId)
|
||||
var watchedSeason entity.WatchedSeason
|
||||
resp := s.db.Clauses(clause.Returning{}).Model(&entity.WatchedSeason{}).Unscoped().Where("id = ? AND user_id = ?", seasonId, userId).Delete(&watchedSeason)
|
||||
resp := s.db.
|
||||
Clauses(clause.Returning{}).
|
||||
Model(&entity.WatchedSeason{}).
|
||||
Unscoped().
|
||||
Where("id = ? AND user_id = ?", seasonId, userId).
|
||||
Delete(&watchedSeason)
|
||||
if resp.Error != nil {
|
||||
slog.Error("Failed when removing a watched season", "error", resp.Error)
|
||||
return entity.Activity{}, errors.New("failed when removing watched season")
|
||||
@@ -148,7 +173,15 @@ func (s *Service) RmWatchedSeason(userId uint, seasonId uint) (entity.Activity,
|
||||
"status": watchedSeason.Status,
|
||||
"rating": watchedSeason.Rating,
|
||||
})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(userId, domain.ActivityAddRequest{WatchedID: watchedSeason.WatchedID, Type: entity.SEASON_REMOVED, Data: string(json)})
|
||||
addedActivity, _ := s.activityProvider.AddActivity(
|
||||
userId,
|
||||
domain.ActivityAddProps{
|
||||
WatchedID: watchedSeason.WatchedID,
|
||||
Type: entity.SEASON_REMOVED,
|
||||
Data: string(json),
|
||||
},
|
||||
false,
|
||||
)
|
||||
return addedActivity, nil
|
||||
}
|
||||
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
|
||||
@@ -100,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)
|
||||
@@ -162,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)
|
||||
@@ -186,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
|
||||
@@ -453,7 +456,7 @@ func (s *Service) AddWatched(
|
||||
slog.Debug("AddWatched: Added watched list item", "item", watched)
|
||||
|
||||
// Finally add activity
|
||||
activityAddReq := domain.ActivityAddRequest{
|
||||
activityAddReq := domain.ActivityAddProps{
|
||||
WatchedID: watched.ID,
|
||||
Type: extraProps.ActivityType,
|
||||
}
|
||||
@@ -466,9 +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)
|
||||
|
||||
@@ -575,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 {
|
||||
@@ -603,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
|
||||
}
|
||||
@@ -665,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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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--;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
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();
|
||||
|
||||
@@ -213,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>
|
||||
|
||||
@@ -337,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.
|
||||
@@ -349,7 +352,10 @@
|
||||
},
|
||||
] as any[];
|
||||
}
|
||||
if (finishDateNode?.textContent) {
|
||||
if (
|
||||
finishDateNode?.textContent &&
|
||||
finishDateNode?.textContent != "0000-00-00"
|
||||
) {
|
||||
l.datesWatched = [new Date(finishDateNode.textContent)];
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
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();
|
||||
|
||||
@@ -262,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}
|
||||
@@ -294,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>
|
||||
@@ -370,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
|
||||
|
||||
@@ -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";
|
||||
@@ -34,6 +35,10 @@
|
||||
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();
|
||||
|
||||
@@ -42,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 () => {
|
||||
@@ -101,14 +107,29 @@
|
||||
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;
|
||||
@@ -239,6 +260,10 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if countAsPlayModalSignal}
|
||||
<CountAsPlayModal onDecision={countAsPlayModalSignal} />
|
||||
{/if}
|
||||
|
||||
{#if requestModalShown}
|
||||
<RequestShow
|
||||
content={show}
|
||||
@@ -260,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}
|
||||
@@ -292,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}
|
||||
@@ -382,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||