Compare commits
54 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 | |||
| a12faf8e72 | |||
| eb1b8b86c7 | |||
| dca1acbbd8 | |||
| 664caa6ba2 | |||
| a425abcdf2 | |||
| 13b15e03d0 | |||
| 3b4a5a27e6 | |||
| 13bd37c32c | |||
| 2d6d3c005c | |||
| f72f950871 | |||
| e4e015daf8 | |||
| 3db89f3214 | |||
| d379d3f147 | |||
| ac8d8769af | |||
| 7a7bd4f566 | |||
| ca82a36d23 | |||
| 1e6a97202b | |||
| 0e54a8c44a | |||
| 5c4c7be115 | |||
| f458eec732 | |||
| 8ad2208162 |
|
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,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.
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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,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 @@
|
||||
3.0.0
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,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()})
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
@@ -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 {
|
||||
|
||||