mirror of
https://github.com/sbondCo/Watcharr.git
synced 2026-08-07 07:14:44 +00:00
(wip) server restructure/refactor
This commit is contained in:
+1
-2
@@ -10,8 +10,7 @@
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"prepare": "svelte-kit sync",
|
||||
"lint": "prettier --check . && eslint .",
|
||||
"format": "prettier --write .",
|
||||
"server": "cd ./server && MODE=DEV go run ."
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.2.12",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
run:
|
||||
MODE=DEV go run .
|
||||
@@ -1,130 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityType string
|
||||
|
||||
// _AUTO activities are for when logic updates something for the user (automations basically).
|
||||
var (
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
|
||||
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
|
||||
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
|
||||
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_AUTO ActivityType = "SEASON_ADDED_AUTO"
|
||||
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
|
||||
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
SEASON_STATUS_CHANGED_AUTO ActivityType = "SEASON_STATUS_CHANGED_AUTO"
|
||||
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
|
||||
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
|
||||
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
|
||||
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
|
||||
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
|
||||
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
GormModel
|
||||
// ID of user this activity is linked to, so it can be easily
|
||||
// secured (users can only view their own activities).
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// ID of watched list item this activity is linked to.
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
// hold new rating - if status changed, this will hold that).
|
||||
Data string `json:"data" gorm:"not null"`
|
||||
// Custom date for the activity, that the user can define.
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityAddRequest struct {
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityUpdateRequest struct {
|
||||
CustomDate time.Time `json:"customDate" binding:"required"`
|
||||
}
|
||||
|
||||
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]Activity, error) {
|
||||
activity := new([]Activity)
|
||||
res := db.Model(&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())
|
||||
return []Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
}
|
||||
|
||||
func addActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (Activity, error) {
|
||||
if ar.WatchedID == 0 {
|
||||
return Activity{}, errors.New("watchedId must be set to add an activity")
|
||||
}
|
||||
activity := Activity{UserID: userId, WatchedID: ar.WatchedID, Type: ar.Type, Data: ar.Data, CustomDate: ar.CustomDate}
|
||||
res := db.Create(&activity)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding activity to database", "error", res.Error.Error())
|
||||
return Activity{}, errors.New("failed adding new activity to database")
|
||||
}
|
||||
slog.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func updateActivity(db *gorm.DB, userId uint, id uint, activityUpdateRequest ActivityUpdateRequest) error {
|
||||
if id == 0 {
|
||||
return errors.New("id must be set to update an activity")
|
||||
}
|
||||
if activityUpdateRequest.CustomDate.IsZero() {
|
||||
return errors.New("customDate must be set to update an activity")
|
||||
}
|
||||
res := db.Model(&Activity{}).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())
|
||||
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.")
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
slog.Debug("Updating activity", "updated_activity", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteActivity(db *gorm.DB, userId uint, id uint) error {
|
||||
if id == 0 {
|
||||
return errors.New("an id must be provided to delete an activity")
|
||||
}
|
||||
res := db.Where("user_id = ?", userId).Delete(&Activity{}, id)
|
||||
if res.Error != nil {
|
||||
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.")
|
||||
return errors.New("failed deleting activity from database")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package cache
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -0,0 +1,34 @@
|
||||
package cfgmodel
|
||||
|
||||
type ArrSettings struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type SonarrSettings struct {
|
||||
ArrSettings
|
||||
QualityProfile int `json:"qualityProfile,omitempty"`
|
||||
RootFolder int `json:"rootFolder,omitempty"`
|
||||
LanguageProfile int `json:"languageProfile,omitempty"`
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
// TODO eventually separate profiles and root for anime
|
||||
// content (i can see diff language profile being useful)
|
||||
}
|
||||
|
||||
func (s *SonarrSettings) Safe() SonarrSettings {
|
||||
s.Key = ""
|
||||
return *s
|
||||
}
|
||||
|
||||
type RadarrSettings struct {
|
||||
ArrSettings
|
||||
QualityProfile int `json:"qualityProfile,omitempty"`
|
||||
RootFolder int `json:"rootFolder,omitempty"`
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
}
|
||||
|
||||
func (s *RadarrSettings) Safe() RadarrSettings {
|
||||
s.Key = ""
|
||||
return *s
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -9,8 +9,10 @@ import (
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
"github.com/sbondCo/Watcharr/config/cfgmodel"
|
||||
"github.com/sbondCo/Watcharr/logging"
|
||||
"github.com/sbondCo/Watcharr/media/igdb"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
|
||||
var DataPath = func() string {
|
||||
@@ -21,6 +23,24 @@ var DataPath = func() string {
|
||||
return path
|
||||
}()
|
||||
|
||||
type TrustedHeaderAuthSetting struct {
|
||||
// Required: Should header auth be enabled?
|
||||
// This bool exists so header auth can be toggled
|
||||
// easily without having to remove configuration.
|
||||
// To be actually enabled, HEADER_NAME must also
|
||||
// be set.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Required: What is the name of the trusted header
|
||||
// that will contain the logged in users username?
|
||||
HeaderName string `json:"headerName"`
|
||||
// Should the frontend attempt auto login if
|
||||
// trusted header auth is enabled.
|
||||
AutoLogin bool `json:"autoLogin"`
|
||||
// Where can we redirect the user to logout
|
||||
// of the auth service?
|
||||
LogoutUrl string `json:"logoutUrl"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
// Used to sign JWT tokens. Make sure to make
|
||||
// it strong, just like a very long, complicated password.
|
||||
@@ -58,9 +78,9 @@ type ServerConfig struct {
|
||||
// VERY DANGEROUS if access is not controlled correctly!
|
||||
HEADER_AUTH TrustedHeaderAuthSetting `json:",omitempty"`
|
||||
|
||||
SONARR []SonarrSettings `json:",omitempty"`
|
||||
RADARR []RadarrSettings `json:",omitempty"`
|
||||
TWITCH game.IGDB `json:",omitempty"`
|
||||
SONARR []cfgmodel.SonarrSettings `json:",omitempty"`
|
||||
RADARR []cfgmodel.RadarrSettings `json:",omitempty"`
|
||||
TWITCH igdb.IGDB `json:",omitempty"`
|
||||
|
||||
// Optional: Schedule for tasks.
|
||||
TASK_SCHEDULE map[string]int `json:",omitempty"`
|
||||
@@ -90,7 +110,7 @@ func (c *ServerConfig) GetSafe() ServerConfig {
|
||||
DEBUG: c.DEBUG,
|
||||
SONARR: c.SONARR, // Dont act safe, this contains sonarr api key, needed for config
|
||||
RADARR: c.RADARR, // Dont act safe, this contains radarr api key, needed for config
|
||||
TWITCH: game.IGDB{
|
||||
TWITCH: igdb.IGDB{
|
||||
ClientID: c.TWITCH.ClientID,
|
||||
ClientSecret: c.TWITCH.ClientSecret,
|
||||
}, // Dont act safe, this contains twitch secrets, needed for config
|
||||
@@ -126,36 +146,97 @@ func (c *ServerConfig) Get(s string) (ServerConfigGetByName, error) {
|
||||
return ServerConfigGetByName{}, errors.New("invalid setting")
|
||||
}
|
||||
|
||||
var (
|
||||
// Our server config.. `readConfig` will overwrite from watcharr.json cfg file.
|
||||
Config = ServerConfig{}
|
||||
)
|
||||
// Update server config property
|
||||
func (c *ServerConfig) UpdateConfig(k string, v any) error {
|
||||
slog.Debug("updateConfig", "k", k, "v", v)
|
||||
if v == nil {
|
||||
return errors.New("invalid value")
|
||||
}
|
||||
if k == "JELLYFIN_HOST" {
|
||||
c.JELLYFIN_HOST = v.(string)
|
||||
} else if k == "USE_EMBY" {
|
||||
c.USE_EMBY = v.(bool)
|
||||
} else if k == "SIGNUP_ENABLED" {
|
||||
c.SIGNUP_ENABLED = v.(bool)
|
||||
} else if k == "TMDB_KEY" {
|
||||
c.TMDB_KEY = v.(string)
|
||||
} else if k == "DEBUG" {
|
||||
c.DEBUG = v.(bool)
|
||||
logging.SetLevel(c.DEBUG)
|
||||
} else if k == "DEFAULT_COUNTRY" {
|
||||
c.DEFAULT_COUNTRY = v.(string)
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
err := c.Write()
|
||||
if err != nil {
|
||||
slog.Error("updateConfig: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
func (c *ServerConfig) Write() error {
|
||||
barej, err := json.MarshalIndent(*c, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
func (c *ServerConfig) SaveTwitchConfig(newt igdb.IGDB) error {
|
||||
// If existing client id and secret are same.. just return here
|
||||
if (c.TWITCH.ClientID != nil && newt.ClientID != nil && c.TWITCH.ClientSecret != nil && newt.ClientSecret != nil) &&
|
||||
*c.TWITCH.ClientID == *newt.ClientID && *c.TWITCH.ClientSecret == *newt.ClientSecret {
|
||||
slog.Info("SaveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
|
||||
return nil
|
||||
}
|
||||
// Update our config
|
||||
c.TWITCH.ClientID = newt.ClientID
|
||||
c.TWITCH.ClientSecret = newt.ClientSecret
|
||||
c.TWITCH.AccessToken = ""
|
||||
c.TWITCH.AccessTokenExpires = time.Time{}
|
||||
// Try to init again
|
||||
err := c.TWITCH.Init()
|
||||
if err != nil {
|
||||
slog.Error("SaveTwitchConfig failed to initialize TWITCH", "error", err)
|
||||
return errors.New("initialization with credentials failed")
|
||||
}
|
||||
err = c.Write()
|
||||
if err != nil {
|
||||
slog.Error("SaveTwitchConfig failed to write config", "error", err)
|
||||
return errors.New("failed to save config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read config file
|
||||
// Calls generateConfig if file doesn't exist
|
||||
func readConfig() error {
|
||||
func readInto(c *ServerConfig) error {
|
||||
cfg, err := os.Open(path.Join(DataPath, "watcharr.json"))
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
slog.Info("Config file doesn't exist... generating.")
|
||||
if err = generateConfig(); err == nil {
|
||||
if genCfg, err := generateConfig(); err == nil {
|
||||
c = genCfg
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer cfg.Close()
|
||||
jsonParser := json.NewDecoder(cfg)
|
||||
if err = jsonParser.Decode(&Config); err != nil {
|
||||
dec := json.NewDecoder(cfg)
|
||||
if err = dec.Decode(&c); err != nil {
|
||||
return err
|
||||
}
|
||||
initFromConfig()
|
||||
initFromConfig(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ensure required config is provided
|
||||
func initFromConfig() error {
|
||||
if Config.JWT_SECRET == "" {
|
||||
func initFromConfig(c *ServerConfig) error {
|
||||
if c.JWT_SECRET == "" {
|
||||
log.Fatal("JWT_SECRET missing from config!")
|
||||
}
|
||||
return nil
|
||||
@@ -163,10 +244,10 @@ func initFromConfig() error {
|
||||
|
||||
// Generate new barebones watcharr.json config file.
|
||||
// Generates a JWT_SECRET and set default config.
|
||||
func generateConfig() error {
|
||||
key, err := generateString(64)
|
||||
func generateConfig() (*ServerConfig, error) {
|
||||
key, err := util.GenerateString(64)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
cfg := ServerConfig{
|
||||
JWT_SECRET: key,
|
||||
@@ -176,150 +257,17 @@ func generateConfig() error {
|
||||
}
|
||||
barej, err := json.MarshalIndent(cfg, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
Config = cfg
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
return &cfg, os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
// Update server config property
|
||||
func updateConfig(k string, v any) error {
|
||||
slog.Debug("updateConfig", "k", k, "v", v)
|
||||
if v == nil {
|
||||
return errors.New("invalid value")
|
||||
// Get server config.
|
||||
// Reads from config file.
|
||||
func Get() (*ServerConfig, error) {
|
||||
c := new(ServerConfig)
|
||||
if err := readInto(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if k == "JELLYFIN_HOST" {
|
||||
Config.JELLYFIN_HOST = v.(string)
|
||||
} else if k == "USE_EMBY" {
|
||||
Config.USE_EMBY = v.(bool)
|
||||
} else if k == "SIGNUP_ENABLED" {
|
||||
Config.SIGNUP_ENABLED = v.(bool)
|
||||
} else if k == "TMDB_KEY" {
|
||||
Config.TMDB_KEY = v.(string)
|
||||
} else if k == "DEBUG" {
|
||||
Config.DEBUG = v.(bool)
|
||||
setLoggingLevel()
|
||||
} else if k == "DEFAULT_COUNTRY" {
|
||||
Config.DEFAULT_COUNTRY = v.(string)
|
||||
} else {
|
||||
return errors.New("invalid setting")
|
||||
}
|
||||
err := writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("updateConfig: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Write current Config to file
|
||||
func writeConfig() error {
|
||||
barej, err := json.MarshalIndent(Config, "", "\t")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755)
|
||||
}
|
||||
|
||||
type ServerFeatures struct {
|
||||
Sonarr bool `json:"sonarr"`
|
||||
Radarr bool `json:"radarr"`
|
||||
Games bool `json:"games"`
|
||||
}
|
||||
|
||||
// Get enabled server functionality from Config.
|
||||
// Mainly so the frontend can store this once and know
|
||||
// which btns should be shown, etc.
|
||||
func getEnabledFeatures(userPerms int) ServerFeatures {
|
||||
var f ServerFeatures
|
||||
if Config.TWITCH.ClientID != nil && Config.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT) {
|
||||
if len(Config.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(Config.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func saveTwitchConfig(c game.IGDB) error {
|
||||
// If existing client id and secret are same.. just return here
|
||||
if (Config.TWITCH.ClientID != nil && c.ClientID != nil && Config.TWITCH.ClientSecret != nil && c.ClientSecret != nil) &&
|
||||
*Config.TWITCH.ClientID == *c.ClientID && *Config.TWITCH.ClientSecret == *c.ClientSecret {
|
||||
slog.Info("saveTwitchConfig: New ClientID and ClientSecret match old ClientID and ClientSecret.. ignoring request to update.")
|
||||
return nil
|
||||
}
|
||||
// Update our config
|
||||
Config.TWITCH.ClientID = c.ClientID
|
||||
Config.TWITCH.ClientSecret = c.ClientSecret
|
||||
Config.TWITCH.AccessToken = ""
|
||||
Config.TWITCH.AccessTokenExpires = time.Time{}
|
||||
// Try to init again
|
||||
err := Config.TWITCH.Init()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to initialize TWITCH", "error", err)
|
||||
return errors.New("initialization with credentials failed")
|
||||
}
|
||||
err = writeConfig()
|
||||
if err != nil {
|
||||
slog.Error("saveTwitchConfig failed to write config", "error", err)
|
||||
return errors.New("failed to save config")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type ServerStats struct {
|
||||
Users int64 `json:"users"`
|
||||
PrivateUsers int64 `json:"privateUsers"`
|
||||
WatchedMovies int64 `json:"watchedMovies"`
|
||||
WatchedShows int64 `json:"watchedShows"`
|
||||
WatchedSeasons int64 `json:"watchedSeasons"`
|
||||
MostWatchedMovie Content `json:"mostWatchedMovie"`
|
||||
MostWatchedShow Content `json:"mostWatchedShow"`
|
||||
Activities int64 `json:"activities"`
|
||||
}
|
||||
|
||||
// Collect and return server stats
|
||||
// I cant sql so this the best yall gettin
|
||||
func getServerStats(db *gorm.DB) ServerStats {
|
||||
stats := ServerStats{}
|
||||
resp := db.Model(&User{}).Count(&stats.Users).Where("private = 1").Count(&stats.PrivateUsers)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Users query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&WatchedSeason{}).Count(&stats.WatchedSeasons)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedSeasons query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Model(&Activity{}).Count(&stats.Activities)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Activities query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "tv").Find(&Watched{}).Count(&stats.WatchedShows)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedShows query failed", "error", resp.Error)
|
||||
}
|
||||
resp = db.Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "movie").Find(&Watched{}).Count(&stats.WatchedMovies)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedMovies query failed", "error", resp.Error)
|
||||
}
|
||||
|
||||
var w Watched
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "tv").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = *w.Content
|
||||
}
|
||||
resp = db.Model(&Watched{}).Select("content_id, COUNT(*) AS mag").Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").Group("content_id").Order("mag DESC").Preload("Content").First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedMovie = *w.Content
|
||||
}
|
||||
return stats
|
||||
return c, nil
|
||||
}
|
||||
@@ -1,556 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/robfig/go-cache"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
MOVIE ContentType = "movie"
|
||||
SHOW ContentType = "tv"
|
||||
// Show episode
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
// inmemory content cache
|
||||
var ContentStore = cache.New(time.Hour*24, time.Minute)
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TmdbID int `json:"tmdbId" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
Status string `json:"status"`
|
||||
Budget uint32 `json:"budget"`
|
||||
Revenue uint32 `json:"revenue"`
|
||||
Runtime uint32 `json:"runtime"`
|
||||
NumberOfEpisodes uint32 `json:"numberOfEpisodes"`
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
|
||||
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
|
||||
func saveContent(db *gorm.DB, c *Content, onlyUpdate bool) error {
|
||||
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
|
||||
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
|
||||
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
|
||||
return errors.New("content missing id or title")
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached content in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title",
|
||||
"poster_path",
|
||||
"overview",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"vote_average",
|
||||
"vote_count",
|
||||
"imdb_id",
|
||||
"status",
|
||||
"budget",
|
||||
"revenue",
|
||||
"runtime",
|
||||
"number_of_episodes",
|
||||
"number_of_seasons",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download("https://image.tmdb.org/t/p/w500"+c.PosterPath, path.Join(DataPath, "img", c.PosterPath), false)
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheContentTv(db *gorm.DB, content TMDBShowDetails, onlyUpdate bool) (Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Name,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: SHOW,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
Status: content.Status,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: content.NumberOfEpisodes,
|
||||
NumberOfSeasons: content.NumberOfSeasons,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func cacheContentMovie(db *gorm.DB, content TMDBMovieDetails, onlyUpdate bool) (Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
|
||||
c := Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Title,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: MOVIE,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
ImdbID: content.ImdbID,
|
||||
Status: content.Status,
|
||||
Budget: content.Budget,
|
||||
Revenue: content.Revenue,
|
||||
Runtime: content.Runtime,
|
||||
}
|
||||
|
||||
err = saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
|
||||
return Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Get content from our cache, or cache it if it doesn't exist.
|
||||
func getOrCacheContent(db *gorm.DB, contentType ContentType, tmdbId int) (Content, error) {
|
||||
var content Content
|
||||
// Look in db for content.
|
||||
db.Where("type = ? AND tmdb_id = ?", contentType, tmdbId).Find(&content)
|
||||
// Create content if not found from our db.
|
||||
if content == (Content{}) {
|
||||
slog.Debug("Content not in db, fetching...", "type", contentType, "tmdbId", tmdbId)
|
||||
|
||||
resp, err := tmdbAPIRequest("/"+string(contentType)+"/"+strconv.Itoa(tmdbId), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: content tmdb api request failed", "error", err)
|
||||
return Content{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
if contentType == "movie" {
|
||||
c := new(TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return Content{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: failed to cache movie content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
} else {
|
||||
c := new(TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return Content{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("getOrCacheContent: failed to cache tv content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
func transformProviders(c *interface{}, country string) {
|
||||
slog.Debug("transformProviders called", "country", country)
|
||||
if cmap, ok := (*c).(map[string]interface{}); ok {
|
||||
if rmap, ok := cmap["results"].(map[string]interface{}); ok {
|
||||
if val, ok := rmap[country]; ok {
|
||||
slog.Debug("transformProviders: Found country.. overwriting whole object", "new_obj", val)
|
||||
if rvmap, ok := val.(map[string]interface{}); ok {
|
||||
rvmap["country"] = country
|
||||
}
|
||||
*c = val
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find country..", "country", country)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find results property..")
|
||||
}
|
||||
} else {
|
||||
slog.Error("transformProviders: Assertion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func searchContent(query string, pageNum int) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBSearchMultiResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchContent", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchContent: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchMovies(query string, pageNum int) (TMDBSearchMoviesResponse, error) {
|
||||
resp := new(TMDBSearchMoviesResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchMovies", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie search request!", "error", err.Error())
|
||||
return TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchTv(query string, pageNum int) (TMDBSearchShowsResponse, error) {
|
||||
resp := new(TMDBSearchShowsResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := CreateCacheKey("searchTv", query, pageNum)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("searchTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv search request!", "error", err.Error())
|
||||
return TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func searchPeople(query string, pageNum int) (TMDBSearchPeopleResponse, error) {
|
||||
resp := new(TMDBSearchPeopleResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := tmdbRequest("/search/person", map[string]string{
|
||||
"query": query,
|
||||
"page": strconv.Itoa(pageNum),
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete people search request!", "error", err.Error())
|
||||
return TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "person"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Search for content by an external id (imdb, etc).
|
||||
// Defaults to imdb if no source if provided (probably most common).
|
||||
func searchByExternalId(id string, source string) (TMDBSearchMultiResponse, error) {
|
||||
resp := new(TMDBFindByExternalIdResponse)
|
||||
if source == "" {
|
||||
source = "imdb"
|
||||
}
|
||||
err := tmdbRequest("/find/"+id, map[string]string{"external_source": source + "_id"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete find/external_id request!", "error", err.Error())
|
||||
return TMDBSearchMultiResponse{}, errors.New("failed to complete find/external_id request")
|
||||
}
|
||||
comb := []TMDBSearchMultiResults{}
|
||||
comb = append(comb, resp.MovieResults...)
|
||||
comb = append(comb, resp.TvResults...)
|
||||
comb = append(comb, resp.PersonResults...)
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return TMDBSearchMultiResponse{TMDBSearchResponse: TMDBSearchResponse[TMDBSearchMultiResults]{
|
||||
Results: comb,
|
||||
TMDBPageFields: TMDBPageFields{
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func movieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (TMDBMovieDetails, error) {
|
||||
resp := new(TMDBMovieDetails)
|
||||
cacheKey := CreateCacheKey("movieDetails", id, country, rParams)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("movieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
return TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentMovie(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func movieCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie cast request!", "error", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete movie cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvDetails(
|
||||
db *gorm.DB,
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (TMDBShowDetails, error) {
|
||||
cacheKey := CreateCacheKey("tvDetails", id, country, rParams)
|
||||
resp := new(TMDBShowDetails)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("tvDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
return TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
transformProviders(&resp.WatchProviders, country)
|
||||
go cacheContentTv(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func tvCredits(id string) (TMDBContentCredits, error) {
|
||||
resp := new(TMDBContentCredits)
|
||||
err := tmdbRequest("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv cast request!", "error", err.Error())
|
||||
return TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func seasonDetails(tvId string, seasonNumber string) (TMDBSeasonDetails, error) {
|
||||
cacheKey := CreateCacheKey("seasonDetails", tvId, seasonNumber)
|
||||
resp := new(TMDBSeasonDetails)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("seasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("seasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func personDetails(id string) (TMDBPersonDetails, error) {
|
||||
resp := new(TMDBPersonDetails)
|
||||
err := tmdbRequest("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func personCredits(id string) (TMDBPersonCombinedCredits, error) {
|
||||
resp := new(TMDBPersonCombinedCredits)
|
||||
err := tmdbRequest("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverMovies() (TMDBDiscoverMovies, error) {
|
||||
cacheKey := CreateCacheKey("discoverMovies")
|
||||
resp := new(TMDBDiscoverMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func discoverTv() (TMDBDiscoverShows, error) {
|
||||
cacheKey := CreateCacheKey("discoverTv")
|
||||
resp := new(TMDBDiscoverShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("discoverTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func allTrending() (TMDBTrendingAll, error) {
|
||||
cacheKey := CreateCacheKey("allTrending")
|
||||
resp := new(TMDBTrendingAll)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("allTrending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func upcomingMovies() (TMDBUpcomingMovies, error) {
|
||||
cacheKey := CreateCacheKey("upcomingMovies")
|
||||
resp := new(TMDBUpcomingMovies)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := tmdbRequest("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func upcomingTv() (TMDBUpcomingShows, error) {
|
||||
cacheKey := CreateCacheKey("upcomingTv")
|
||||
resp := new(TMDBUpcomingShows)
|
||||
if GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := tmdbRequest("/discover/tv", map[string]string{
|
||||
"page": "1",
|
||||
"first_air_date.gte": mind,
|
||||
"first_air_date.lte": maxd,
|
||||
"sort_by": "popularity.desc",
|
||||
"with_type": "2|3",
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func regions() (TMDBRegions, error) {
|
||||
resp := new(TMDBRegions)
|
||||
err := tmdbRequest("/watch/providers/regions", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete regions request!", "error", err.Error())
|
||||
return TMDBRegions{}, errors.New("failed to complete regions request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
// All the functions that help us turn a TMDB response struct
|
||||
// into one that will also include Watched data.
|
||||
// This process is very verbose. As far as I am aware, golangs
|
||||
// generics are not mature (powerful) enough to support us doing
|
||||
// this all with one function.
|
||||
//
|
||||
// TODO When possible look at turning all these funcs into one that
|
||||
// is reuable for any tmdb search response type.
|
||||
//
|
||||
// Each function will basically perform these simple steps:
|
||||
// 1. Repackage tmdb response so we can add Watched data to
|
||||
// 2. Get all watched data for the tmdb results
|
||||
// 4. Add any watched data to our new *WithWatched struct
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func searchContentAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMultiResponse,
|
||||
) TMDBSearchMultiResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMultiResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMultiResultsWithWatched{
|
||||
TMDBSearchMultiResults: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchMoviesResponse,
|
||||
) TMDBSearchMoviesResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchMoviesResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMovieResultWithWatched{
|
||||
TMDBSearchMovieResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func searchTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBSearchShowsResponse,
|
||||
) TMDBSearchShowsResponseWithWatched {
|
||||
withWatchedResp := TMDBSearchShowsResponseWithWatched{}
|
||||
withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchShowsResultWithWatched{
|
||||
TMDBSearchShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func movieDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBMovieDetails,
|
||||
) TMDBMovieDetailsWithWatched {
|
||||
withWatchedResp := TMDBMovieDetailsWithWatched{}
|
||||
withWatchedResp.TMDBMovieDetailsBase = content.TMDBMovieDetailsBase
|
||||
// Append watched list entry if exists
|
||||
if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), MOVIE); err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
withWatchedResp.FailedToGetWatched = true
|
||||
}
|
||||
} else {
|
||||
withWatchedResp.Watched = &watchedEntry
|
||||
}
|
||||
// Add similar content with any watched entries
|
||||
similarContentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Similar.Results {
|
||||
withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBMovieSimilarResultWithWatched{
|
||||
TMDBMovieSimilarResult: v,
|
||||
})
|
||||
similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Similar.Results {
|
||||
if vv.ID == v.Content.TmdbID && string(MOVIE) == string(v.Content.Type) {
|
||||
withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func tvDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBShowDetails,
|
||||
) TMDBShowDetailsWithWatched {
|
||||
withWatchedResp := TMDBShowDetailsWithWatched{}
|
||||
withWatchedResp.TMDBShowDetailsBase = content.TMDBShowDetailsBase
|
||||
// Append watched list entry if exists
|
||||
if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), SHOW); err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
withWatchedResp.FailedToGetWatched = true
|
||||
}
|
||||
} else {
|
||||
withWatchedResp.Watched = &watchedEntry
|
||||
}
|
||||
// Add similar content with any watched entries
|
||||
similarContentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Similar.Results {
|
||||
withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBShowSimilarResultWithWatched{
|
||||
TMDBShowSimilarResult: v,
|
||||
})
|
||||
similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Similar.Results {
|
||||
if vv.ID == v.Content.TmdbID && string(SHOW) == string(v.Content.Type) {
|
||||
withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func allTrendingAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBTrendingAll,
|
||||
) TMDBTrendingAllWithWatched {
|
||||
withWatchedResp := TMDBTrendingAllWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBTrendingAllResultWithWatched{
|
||||
TMDBTrendingAllResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
ContentType(v.MediaType),
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverShows,
|
||||
) TMDBDiscoverShowsWithWatched {
|
||||
withWatchedResp := TMDBDiscoverShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverShowsResultWithWatched{
|
||||
TMDBDiscoverShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingTvAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingShows,
|
||||
) TMDBUpcomingShowsWithWatched {
|
||||
withWatchedResp := TMDBUpcomingShowsWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingShowsResultWithWatched{
|
||||
TMDBUpcomingShowsResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
SHOW,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func discoverMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBDiscoverMovies,
|
||||
) TMDBDiscoverMoviesWithWatched {
|
||||
withWatchedResp := TMDBDiscoverMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverMoviesResultWithWatched{
|
||||
TMDBDiscoverMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
|
||||
func upcomingMoviesAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content TMDBUpcomingMovies,
|
||||
) TMDBUpcomingMoviesWithWatched {
|
||||
withWatchedResp := TMDBUpcomingMoviesWithWatched{}
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range content.Results {
|
||||
withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingMoviesResultWithWatched{
|
||||
TMDBUpcomingMoviesResult: v,
|
||||
})
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.ID,
|
||||
MOVIE,
|
||||
})
|
||||
}
|
||||
if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.Results {
|
||||
if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Create a new database connection.
|
||||
func New() (*gorm.DB, error) {
|
||||
// Open the database.
|
||||
db, err := gorm.Open(
|
||||
sqlite.Open(path.Join(config.DataPath, "watcharr.db")),
|
||||
&gorm.Config{TranslateError: true},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Perform auto migration.
|
||||
err = db.AutoMigrate(
|
||||
&entity.User{},
|
||||
&entity.UserServices{},
|
||||
&entity.Content{},
|
||||
&entity.Watched{},
|
||||
&entity.WatchedSeason{},
|
||||
&entity.WatchedEpisode{},
|
||||
&entity.Activity{},
|
||||
&entity.Token{},
|
||||
&entity.Follow{},
|
||||
&entity.Image{},
|
||||
&entity.Game{},
|
||||
&entity.ArrRequest{},
|
||||
&entity.Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dbmodel
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GormModel struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
)
|
||||
|
||||
type ActivityType string
|
||||
|
||||
// _AUTO activities are for when logic updates something for the user (automations basically).
|
||||
var (
|
||||
ADDED_WATCHED ActivityType = "ADDED_WATCHED"
|
||||
REMOVED_WATCHED ActivityType = "REMOVED_WATCHED"
|
||||
RATING_CHANGED ActivityType = "RATING_CHANGED"
|
||||
STATUS_CHANGED ActivityType = "STATUS_CHANGED"
|
||||
STATUS_CHANGED_AUTO ActivityType = "STATUS_CHANGED_AUTO"
|
||||
THOUGHTS_CHANGED ActivityType = "THOUGHTS_CHANGED"
|
||||
THOUGHTS_REMOVED ActivityType = "THOUGHTS_REMOVED"
|
||||
IMPORTED_WATCHED ActivityType = "IMPORTED_WATCHED"
|
||||
IMPORTED_WATCHED_JF ActivityType = "IMPORTED_WATCHED_JF"
|
||||
IMPORTED_WATCHED_PLEX ActivityType = "IMPORTED_WATCHED_PLEX"
|
||||
IMPORTED_RATING ActivityType = "IMPORTED_RATING" // Imported rating, but with no rating acts as original import of content to old platform (where they are importing from) activity
|
||||
IMPORTED_ADDED_WATCHED ActivityType = "IMPORTED_ADDED_WATCHED" // Imported watched date, so we can save the original watch dates of content from users old platform (where they are importing from).
|
||||
IMPORTED_ADDED_WATCHED_JF ActivityType = "IMPORTED_ADDED_WATCHED_JF"
|
||||
IMPORTED_ADDED_WATCHED_PLEX ActivityType = "IMPORTED_ADDED_WATCHED_PLEX"
|
||||
SEASON_ADDED ActivityType = "SEASON_ADDED"
|
||||
SEASON_ADDED_AUTO ActivityType = "SEASON_ADDED_AUTO"
|
||||
SEASON_ADDED_JF ActivityType = "SEASON_ADDED_JF"
|
||||
SEASON_ADDED_PLEX ActivityType = "SEASON_ADDED_PLEX"
|
||||
SEASON_REMOVED ActivityType = "SEASON_REMOVED"
|
||||
SEASON_RATING_CHANGED ActivityType = "SEASON_RATING_CHANGED"
|
||||
SEASON_STATUS_CHANGED ActivityType = "SEASON_STATUS_CHANGED"
|
||||
SEASON_STATUS_CHANGED_AUTO ActivityType = "SEASON_STATUS_CHANGED_AUTO"
|
||||
EPISODE_ADDED ActivityType = "EPISODE_ADDED"
|
||||
EPISODE_ADDED_JF ActivityType = "EPISODE_ADDED_JF"
|
||||
EPISODE_ADDED_PLEX ActivityType = "EPISODE_ADDED_PLEX"
|
||||
EPISODE_REMOVED ActivityType = "EPISODE_REMOVED"
|
||||
EPISODE_RATING_CHANGED ActivityType = "EPISODE_RATING_CHANGED"
|
||||
EPISODE_STATUS_CHANGED ActivityType = "EPISODE_STATUS_CHANGED"
|
||||
)
|
||||
|
||||
type Activity struct {
|
||||
dbmodel.GormModel
|
||||
// ID of user this activity is linked to, so it can be easily
|
||||
// secured (users can only view their own activities).
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// ID of watched list item this activity is linked to.
|
||||
WatchedID uint `json:"watchedId" gorm:"not null"`
|
||||
// Type of activity.
|
||||
Type ActivityType `json:"type" gorm:"not null"`
|
||||
// Holds custom data (ex, if rating changed, this can
|
||||
// hold new rating - if status changed, this will hold that).
|
||||
Data string `json:"data" gorm:"not null"`
|
||||
// Custom date for the activity, that the user can define.
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type ArrRequestStatus string
|
||||
|
||||
const (
|
||||
// Pending approval from an admin.
|
||||
ARR_REQUEST_PENDING ArrRequestStatus = "PENDING"
|
||||
// Request has been approved and should be added to sonarr/radarr.
|
||||
ARR_REQUEST_APPROVED ArrRequestStatus = "APPROVED"
|
||||
ARR_REQUEST_AUTO_APPROVED ArrRequestStatus = "AUTO_APPROVED"
|
||||
// Request has been denied, not adding content.
|
||||
ARR_REQUEST_DENIED ArrRequestStatus = "DENIED"
|
||||
// Content was found on sonarr/radarr already, nothing needs to be done.
|
||||
ARR_REQUEST_FOUND ArrRequestStatus = "FOUND"
|
||||
)
|
||||
|
||||
type ArrRequest struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
// Username of `User`.
|
||||
// We don't want to send back the entire user object, just their name.
|
||||
// Not stored in DB, only used for our response from api.
|
||||
Username string `json:"username" gorm:"-"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
// Server names are used as an identifier
|
||||
ServerName string `json:"serverName" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
// Sonarr/Radarrs seriesId/movieId
|
||||
ArrID int `json:"arrId"`
|
||||
// Tracked request status
|
||||
Status ArrRequestStatus `json:"status" gorm:"default:PENDING"`
|
||||
// Full request made by user (arr.SonarrRequest / arr.RadarrRequest)
|
||||
// so we know how to fulfil the request if approved.
|
||||
RequestJson string `json:"requestJson"`
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type UserType uint8
|
||||
|
||||
var (
|
||||
WATCHARR_USER UserType = 0
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
// Registered via trusted header auth
|
||||
PROXY_USER UserType = 3
|
||||
)
|
||||
|
||||
// User Perms
|
||||
// iota auto increments for us so when adding new
|
||||
// perms, add to bottom as to not change other perm
|
||||
// values.
|
||||
const (
|
||||
PERM_NONE int = 1 << iota
|
||||
PERM_ADMIN
|
||||
PERM_REQUEST_CONTENT
|
||||
PERM_REQUEST_CONTENT_AUTO_APPROVE
|
||||
)
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
// Each service may use the fields in their own way.
|
||||
// Unique index applied between service name and clientID
|
||||
// to ensure no duplicates (no need to apply it against
|
||||
// user_id, no accounts should share an integration).
|
||||
//
|
||||
// Plex:
|
||||
// - AuthToken : Used for requests against plex.tv
|
||||
// - AuthToken2 : Used for requests against home plex server.
|
||||
type UserServices struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Service/integration name
|
||||
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
// The users id on the third party service
|
||||
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
AuthToken string `gorm:"not null;" json:"-"`
|
||||
// Second auth token, generic name so future services can use it without extra confusion.
|
||||
// Ex: We require a second auth token for use with our local server for Plex.
|
||||
AuthToken2 string `json:"-"`
|
||||
UserID uint `gorm:"not null;" json:"-"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
Memory uint32
|
||||
Iterations uint32
|
||||
Parallelism uint8
|
||||
SaltLength uint32
|
||||
KeyLength uint32
|
||||
}
|
||||
|
||||
func GetPassArgonParams() *ArgonParams {
|
||||
return &ArgonParams{
|
||||
Memory: 64 * 1024,
|
||||
Iterations: 3,
|
||||
Parallelism: 2,
|
||||
SaltLength: 16,
|
||||
KeyLength: 32,
|
||||
}
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type ContentType string
|
||||
|
||||
const (
|
||||
MOVIE ContentType = "movie"
|
||||
SHOW ContentType = "tv"
|
||||
// Show episode
|
||||
SHOW_EPISODE ContentType = "tv_episode"
|
||||
)
|
||||
|
||||
// For storing cached content, so we can serve the basic local data for watched list to work
|
||||
type Content struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
TmdbID int `json:"tmdbId" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
Title string `json:"title"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
Overview string `json:"overview"`
|
||||
Type ContentType `json:"type" gorm:"uniqueIndex:contentidtotypeidx;not null"`
|
||||
ReleaseDate *time.Time `json:"release_date,omitempty"`
|
||||
Popularity float32 `json:"popularity"`
|
||||
VoteAverage float32 `json:"vote_average"`
|
||||
VoteCount uint32 `json:"vote_count"`
|
||||
ImdbID string `json:"imdb_id"`
|
||||
Status string `json:"status"`
|
||||
Budget uint32 `json:"budget"`
|
||||
Revenue uint32 `json:"revenue"`
|
||||
Runtime uint32 `json:"runtime"`
|
||||
NumberOfEpisodes uint32 `json:"numberOfEpisodes"`
|
||||
NumberOfSeasons uint32 `json:"numberOfSeasons"`
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// Database struct, only internal.
|
||||
type Follow struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
UserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null;check:user_id != followed_user_id" json:"-"`
|
||||
User User `json:"-"`
|
||||
FollowedUserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null" json:"-"`
|
||||
FollowedUser User `json:"-"`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// For storing cached games, so we can serve the basic local data for watched list to work
|
||||
type Game struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
IgdbID int `json:"igdbId" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name"`
|
||||
CoverID string `json:"coverId"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
// First release date
|
||||
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"ratingCount"`
|
||||
Status int `json:"status"`
|
||||
Category int `json:"category"`
|
||||
// Arrays turned to strings that may be useful
|
||||
GameModes string `json:"gameModes"`
|
||||
Genres string `json:"genres"`
|
||||
Platforms string `json:"platforms"`
|
||||
// Id to poster image row (cached game cover)
|
||||
PosterID *uint `json:"-"`
|
||||
Poster *Image `json:"poster,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
// For user uploaded images
|
||||
type Image struct {
|
||||
ID uint `gorm:"primarykey" json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Hash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
BlurHash string `json:"blurHash"`
|
||||
// Path constructable from hash alone, but I can't decide
|
||||
// if I should have this or not so I figure it's easier
|
||||
// to remove it later than to add it later....... -_-
|
||||
Path string `gorm:"not null" json:"path"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package entity
|
||||
|
||||
import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
|
||||
type Tag struct {
|
||||
dbmodel.GormModel
|
||||
// ID of user that own this tag.
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// Name of the tag.
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
// Hex of text color.
|
||||
Color string `json:"color"`
|
||||
// Hex of background color.
|
||||
BgColor string `json:"bgColor"`
|
||||
// All watched items.
|
||||
Watched []Watched `json:"watched,omitempty" gorm:"many2many:watched_tags;"`
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package entity
|
||||
|
||||
import "time"
|
||||
|
||||
type TokenType string
|
||||
|
||||
var (
|
||||
TOKENTYPE_ADMIN TokenType = "ADMIN"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Value string `gorm:"not null"`
|
||||
Type TokenType `gorm:"not null"`
|
||||
UserID uint `gorm:"not null"`
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
// This is incase different users with same name from different services try to signup.
|
||||
type User struct {
|
||||
dbmodel.GormModel
|
||||
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
|
||||
Password string `gorm:"not null" json:"password" binding:"required"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
// The type of user/which auth service they originate from.
|
||||
// Empty if from Watcharr, or the name of the service (eg. jellyfin)
|
||||
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
|
||||
// ID of user from the third party service, this will be used purely for lookup of user at signin.
|
||||
ThirdPartyID string `json:"-"`
|
||||
// Auth token from third party (jellyfin)
|
||||
ThirdPartyAuth string `json:"-"`
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
// All Tags
|
||||
Tags []Tag `json:"-"`
|
||||
// Users permissions
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
|
||||
func (u *User) GetSafe() PublicUser {
|
||||
return PublicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: u.Avatar,
|
||||
Bio: u.Bio,
|
||||
}
|
||||
}
|
||||
|
||||
// This struct uses pointer to the values, so in update user settings,
|
||||
// we can tell which setting is being updated (if not nil..).
|
||||
type UserSettings struct {
|
||||
// Is profile private
|
||||
Private *bool `gorm:"default:false" json:"private"`
|
||||
// Are watched list content thoughts public (profile must also be public is false)
|
||||
PrivateThoughts *bool `gorm:"default:false" json:"privateThoughts"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
// If user wants previously watched items to show in 'Finished' filter,
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
// User's country to get correct content streaming providers.
|
||||
Country *string `gorm:"default:'US'" json:"country"`
|
||||
// Does the user want show, season and episode automations enabled.
|
||||
AutomateShowStatuses *bool `gorm:"default:true" json:"automateShowStatuses"`
|
||||
// Rating system user wants to use (frontend only).
|
||||
// RatingSystem enum in frontend maxes out at 3, so just max=3 on this and we should be gut.
|
||||
RatingSystem *int `json:"ratingSystem" binding:"omitempty,max=3"`
|
||||
// Rating step for supported rating systems (frontend only, enum goes up to 2).
|
||||
RatingStep *int `json:"ratingStep" binding:"omitempty,max=2"`
|
||||
}
|
||||
|
||||
// Public user details for search results
|
||||
type PublicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
// Private user details, for returning users details to themselves
|
||||
type PrivateUser struct {
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package entity
|
||||
|
||||
import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
|
||||
type WatchedStatus string
|
||||
|
||||
const (
|
||||
FINISHED WatchedStatus = "FINISHED"
|
||||
WATCHING WatchedStatus = "WATCHING"
|
||||
PLANNED WatchedStatus = "PLANNED"
|
||||
HOLD WatchedStatus = "HOLD"
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
type Watched struct {
|
||||
dbmodel.GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
// float so we can support decimal ratings.
|
||||
// Ratings should still always be saved as out of 10.0,
|
||||
// so they can be viewed with any ratings setting in the client.
|
||||
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Pinned bool `json:"pinned" gorm:"default:false;not null"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
GameID *int `json:"-" gorm:"uniqueIndex:userngamidx"`
|
||||
Game *Game `json:"game,omitempty"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:watched_tags;"`
|
||||
// The last season that was viewed by the user for this watched entry.
|
||||
// Only applies to tv shows of course.
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package entity
|
||||
|
||||
import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
|
||||
// UniqueIndex applied between WatchedID, SeasonNum and EpisodeNum to avoid duplicates incase logic fails.
|
||||
//
|
||||
// Episodes on tmdb are only queried by season number + episode number, not possible via episode id,
|
||||
// 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
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
EpisodeNumber int `json:"episodeNumber" gorm:"uniqueIndex:we_watched_to_ens;not null"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package entity
|
||||
|
||||
import "github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
|
||||
// UniqueIndex applied between WatchedID and SeasonNumber to avoid duplicates incase logic fails.
|
||||
type WatchedSeason struct {
|
||||
dbmodel.GormModel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
package domain
|
||||
@@ -0,0 +1,81 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ActivityAddRequest struct {
|
||||
WatchedID uint `json:"watchedId" binding:"required"`
|
||||
Type entity.ActivityType `json:"type" binding:"required"`
|
||||
Data string `json:"data" binding:"required"`
|
||||
CustomDate *time.Time `json:"customDate,omitempty"`
|
||||
}
|
||||
|
||||
type ActivityUpdateRequest struct {
|
||||
CustomDate time.Time `json:"customDate" binding:"required"`
|
||||
}
|
||||
|
||||
func getActivity(db *gorm.DB, userId uint, watchedId uint) ([]entity.Activity, error) {
|
||||
activity := new([]entity.Activity)
|
||||
res := 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())
|
||||
return []entity.Activity{}, errors.New("failed getting activity")
|
||||
}
|
||||
return *activity, nil
|
||||
}
|
||||
|
||||
func AddActivity(db *gorm.DB, userId uint, ar ActivityAddRequest) (entity.Activity, error) {
|
||||
if ar.WatchedID == 0 {
|
||||
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}
|
||||
res := 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.Debug("Adding activity", "added_activity", activity)
|
||||
return activity, nil
|
||||
}
|
||||
|
||||
func updateActivity(db *gorm.DB, userId uint, id uint, activityUpdateRequest ActivityUpdateRequest) error {
|
||||
if id == 0 {
|
||||
return errors.New("id must be set to update an activity")
|
||||
}
|
||||
if activityUpdateRequest.CustomDate.IsZero() {
|
||||
return errors.New("customDate must be set to update an activity")
|
||||
}
|
||||
res := db.Model(&entity.Activity{}).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())
|
||||
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.")
|
||||
return errors.New("failed updating activity in database")
|
||||
}
|
||||
slog.Debug("Updating activity", "updated_activity", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteActivity(db *gorm.DB, userId uint, id uint) error {
|
||||
if id == 0 {
|
||||
return errors.New("an id must be provided to delete an activity")
|
||||
}
|
||||
res := db.Where("user_id = ?", userId).Delete(&entity.Activity{}, id)
|
||||
if res.Error != nil {
|
||||
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.")
|
||||
return errors.New("failed deleting activity from database")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package activity
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func (r *Router) GetActivity(c *gin.Context) {
|
||||
watchedId, err := strconv.ParseUint(c.Param("watchedId"), 10, 32)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "check watched id route param"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
activity, err := getActivity(r.br.DB, userId, uint(watchedId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, activity)
|
||||
}
|
||||
|
||||
func (r *Router) AddActivity(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar ActivityAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := AddActivity(r.br.DB, 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)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
var activityUpdateRequest ActivityUpdateRequest
|
||||
err = c.ShouldBindJSON(&activityUpdateRequest)
|
||||
if err == nil {
|
||||
err = updateActivity(r.br.DB, userId, uint(id), activityUpdateRequest)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) DeleteActivity(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("Could not process activity id when attempting a deletion", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
err = deleteActivity(r.br.DB, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -1,44 +1,14 @@
|
||||
package main
|
||||
package arr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/config/cfgmodel"
|
||||
)
|
||||
|
||||
type ArrSettings struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
type SonarrSettings struct {
|
||||
ArrSettings
|
||||
QualityProfile int `json:"qualityProfile,omitempty"`
|
||||
RootFolder int `json:"rootFolder,omitempty"`
|
||||
LanguageProfile int `json:"languageProfile,omitempty"`
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
// TODO eventually separate profiles and root for anime content (i can see diff language profile being useful)
|
||||
}
|
||||
|
||||
func (s *SonarrSettings) safe() SonarrSettings {
|
||||
s.Key = ""
|
||||
return *s
|
||||
}
|
||||
|
||||
type RadarrSettings struct {
|
||||
ArrSettings
|
||||
QualityProfile int `json:"qualityProfile,omitempty"`
|
||||
RootFolder int `json:"rootFolder,omitempty"`
|
||||
AutomaticSearch bool `json:"automaticSearch"`
|
||||
}
|
||||
|
||||
func (s *RadarrSettings) safe() RadarrSettings {
|
||||
s.Key = ""
|
||||
return *s
|
||||
}
|
||||
|
||||
type ArrTestParams struct {
|
||||
Host string `json:"host,omitempty"`
|
||||
Key string `json:"key,omitempty"`
|
||||
@@ -93,114 +63,115 @@ func testRadarr(p ArrTestParams) (RadarrTestResponse, error) {
|
||||
return RadarrTestResponse{QualityProfiles: qps, RootFolders: rfs}, nil
|
||||
}
|
||||
|
||||
// TODO any way to simplify (deduplicate/reuse) these methods (and the whole file tbh) would be very good
|
||||
// TODO any way to simplify (deduplicate/reuse) these
|
||||
// methods (and the whole file tbh) would be very good
|
||||
|
||||
// Add sonarr server to config
|
||||
func addSonarr(s SonarrSettings) error {
|
||||
for _, v := range Config.SONARR {
|
||||
func addSonarr(cfg *config.ServerConfig, s cfgmodel.SonarrSettings) error {
|
||||
for _, v := range cfg.SONARR {
|
||||
if v.Name == s.Name {
|
||||
// Server exists with this name...
|
||||
return errors.New("server with that name already exists")
|
||||
}
|
||||
}
|
||||
Config.SONARR = append(Config.SONARR, s)
|
||||
writeConfig()
|
||||
cfg.SONARR = append(cfg.SONARR, s)
|
||||
cfg.Write()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Edit sonarr server in config
|
||||
func editSonarr(s SonarrSettings) error {
|
||||
for i, v := range Config.SONARR {
|
||||
func editSonarr(cfg *config.ServerConfig, s cfgmodel.SonarrSettings) error {
|
||||
for i, v := range cfg.SONARR {
|
||||
if v.Name == s.Name {
|
||||
Config.SONARR[i] = s
|
||||
writeConfig()
|
||||
cfg.SONARR[i] = s
|
||||
cfg.Write()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't edit server that does not exist")
|
||||
}
|
||||
|
||||
func rmSonarr(name string) error {
|
||||
for i, v := range Config.SONARR {
|
||||
func rmSonarr(cfg *config.ServerConfig, name string) error {
|
||||
for i, v := range cfg.SONARR {
|
||||
if v.Name == name {
|
||||
Config.SONARR = append(Config.SONARR[:i], Config.SONARR[i+1:]...)
|
||||
writeConfig()
|
||||
cfg.SONARR = append(cfg.SONARR[:i], cfg.SONARR[i+1:]...)
|
||||
cfg.Write()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't remove a server that does not exist")
|
||||
}
|
||||
|
||||
func getSonarr(name string) (SonarrSettings, error) {
|
||||
for i, v := range Config.SONARR {
|
||||
func getSonarr(cfg *config.ServerConfig, name string) (cfgmodel.SonarrSettings, error) {
|
||||
for i, v := range cfg.SONARR {
|
||||
if v.Name == name {
|
||||
return Config.SONARR[i], nil
|
||||
return cfg.SONARR[i], nil
|
||||
}
|
||||
}
|
||||
return SonarrSettings{}, errors.New("server not found")
|
||||
return cfgmodel.SonarrSettings{}, errors.New("server not found")
|
||||
}
|
||||
|
||||
// Get list of sonarr servers without api keys.
|
||||
// Regular users with access to adding to sonarr will request this.
|
||||
func getSonarrsSafe() []SonarrSettings {
|
||||
s := []SonarrSettings{}
|
||||
for _, v := range Config.SONARR {
|
||||
s = append(s, v.safe())
|
||||
func getSonarrsSafe(cfg *config.ServerConfig) []cfgmodel.SonarrSettings {
|
||||
s := []cfgmodel.SonarrSettings{}
|
||||
for _, v := range cfg.SONARR {
|
||||
s = append(s, v.Safe())
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Add radarr server to config
|
||||
func addRadarr(s RadarrSettings) error {
|
||||
for _, v := range Config.RADARR {
|
||||
func addRadarr(cfg *config.ServerConfig, s cfgmodel.RadarrSettings) error {
|
||||
for _, v := range cfg.RADARR {
|
||||
if v.Name == s.Name {
|
||||
// Server exists with this name...
|
||||
return errors.New("server with that name already exists")
|
||||
}
|
||||
}
|
||||
Config.RADARR = append(Config.RADARR, s)
|
||||
writeConfig()
|
||||
cfg.RADARR = append(cfg.RADARR, s)
|
||||
cfg.Write()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Edit radarr server in config
|
||||
func editRadarr(s RadarrSettings) error {
|
||||
for i, v := range Config.RADARR {
|
||||
func editRadarr(cfg *config.ServerConfig, s cfgmodel.RadarrSettings) error {
|
||||
for i, v := range cfg.RADARR {
|
||||
if v.Name == s.Name {
|
||||
Config.RADARR[i] = s
|
||||
writeConfig()
|
||||
cfg.RADARR[i] = s
|
||||
cfg.Write()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't edit server that does not exist")
|
||||
}
|
||||
|
||||
func rmRadarr(name string) error {
|
||||
for i, v := range Config.RADARR {
|
||||
func rmRadarr(cfg *config.ServerConfig, name string) error {
|
||||
for i, v := range cfg.RADARR {
|
||||
if v.Name == name {
|
||||
Config.RADARR = append(Config.RADARR[:i], Config.RADARR[i+1:]...)
|
||||
writeConfig()
|
||||
cfg.RADARR = append(cfg.RADARR[:i], cfg.RADARR[i+1:]...)
|
||||
cfg.Write()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("can't remove a server that does not exist")
|
||||
}
|
||||
|
||||
func getRadarr(name string) (RadarrSettings, error) {
|
||||
for i, v := range Config.RADARR {
|
||||
func getRadarr(cfg *config.ServerConfig, name string) (cfgmodel.RadarrSettings, error) {
|
||||
for i, v := range cfg.RADARR {
|
||||
if v.Name == name {
|
||||
return Config.RADARR[i], nil
|
||||
return cfg.RADARR[i], nil
|
||||
}
|
||||
}
|
||||
return RadarrSettings{}, errors.New("server not found")
|
||||
return cfgmodel.RadarrSettings{}, errors.New("server not found")
|
||||
}
|
||||
|
||||
// Get list of radarr servers without api keys.
|
||||
// Regular users with access to adding to radarr will request this.
|
||||
func getRadarrsSafe() []RadarrSettings {
|
||||
s := []RadarrSettings{}
|
||||
for _, v := range Config.RADARR {
|
||||
s = append(s, v.safe())
|
||||
func getRadarrsSafe(cfg *config.ServerConfig) []cfgmodel.RadarrSettings {
|
||||
s := []cfgmodel.RadarrSettings{}
|
||||
for _, v := range cfg.RADARR {
|
||||
s = append(s, v.Safe())
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package arr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
)
|
||||
|
||||
type ArrDetailsResponse struct {
|
||||
@@ -31,8 +32,8 @@ type SonarrDetailsResponse struct {
|
||||
Items []SonarrDetailsResponseItem `json:"items"`
|
||||
}
|
||||
|
||||
func getRadarrQueueDetails(serverName string, arrId string) (*ArrDetailsResponse, error) {
|
||||
server, err := getRadarr(serverName)
|
||||
func getRadarrQueueDetails(cfg *config.ServerConfig, serverName string, arrId string) (*ArrDetailsResponse, error) {
|
||||
server, err := getRadarr(cfg, serverName)
|
||||
if err != nil {
|
||||
slog.Error("getRadarrQueueDetails: Failed to get server", "error", err)
|
||||
return &ArrDetailsResponse{}, errors.New("failed to get server")
|
||||
@@ -64,8 +65,8 @@ func getRadarrQueueDetails(serverName string, arrId string) (*ArrDetailsResponse
|
||||
return &adr, nil
|
||||
}
|
||||
|
||||
func getSonarrQueueDetails(serverName string, arrId string) (*SonarrDetailsResponse, error) {
|
||||
server, err := getSonarr(serverName)
|
||||
func getSonarrQueueDetails(cfg *config.ServerConfig, serverName string, arrId string) (*SonarrDetailsResponse, error) {
|
||||
server, err := getSonarr(cfg, serverName)
|
||||
if err != nil {
|
||||
slog.Error("getSonarrQueueDetails: Failed to get server", "error", err)
|
||||
return &SonarrDetailsResponse{}, errors.New("failed to get server")
|
||||
@@ -119,14 +120,14 @@ func getSonarrQueueDetails(serverName string, arrId string) (*SonarrDetailsRespo
|
||||
// Refresh download queues for our sonarr/radarr servers.
|
||||
// If the queues don't refresh regularly, our queue detail
|
||||
// calls will just always return the same info.
|
||||
func refreshArrQueues() {
|
||||
func RefreshArrQueues(cfg *config.ServerConfig) {
|
||||
slog.Debug("refreshArrQueues: Refreshing queues for all configured arr servers.")
|
||||
// We don't care about responses, errors will be logged by the RunCommand func.
|
||||
for _, v := range Config.RADARR {
|
||||
for _, v := range cfg.RADARR {
|
||||
radarr := arr.New(arr.RADARR, &v.Host, &v.Key)
|
||||
radarr.RunCommand("RefreshMonitoredDownloads")
|
||||
}
|
||||
for _, v := range Config.SONARR {
|
||||
for _, v := range cfg.SONARR {
|
||||
sonarr := arr.New(arr.SONARR, &v.Host, &v.Key)
|
||||
sonarr.RunCommand("RefreshMonitoredDownloads")
|
||||
}
|
||||
@@ -1,54 +1,19 @@
|
||||
package main
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/permission"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ArrRequestStatus string
|
||||
|
||||
const (
|
||||
// Pending approval from an admin.
|
||||
ARR_REQUEST_PENDING ArrRequestStatus = "PENDING"
|
||||
// Request has been approved and should be added to sonarr/radarr.
|
||||
ARR_REQUEST_APPROVED ArrRequestStatus = "APPROVED"
|
||||
ARR_REQUEST_AUTO_APPROVED ArrRequestStatus = "AUTO_APPROVED"
|
||||
// Request has been denied, not adding content.
|
||||
ARR_REQUEST_DENIED ArrRequestStatus = "DENIED"
|
||||
// Content was found on sonarr/radarr already, nothing needs to be done.
|
||||
ARR_REQUEST_FOUND ArrRequestStatus = "FOUND"
|
||||
)
|
||||
|
||||
type ArrRequest struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
// Username of `User`.
|
||||
// We don't want to send back the entire user object, just their name.
|
||||
// Not stored in DB, only used for our response from api.
|
||||
Username string `json:"username" gorm:"-"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
// Server names are used as an identifier
|
||||
ServerName string `json:"serverName" gorm:"uniqueIndex:sn_to_cid;not null"`
|
||||
// Sonarr/Radarrs seriesId/movieId
|
||||
ArrID int `json:"arrId"`
|
||||
// Tracked request status
|
||||
Status ArrRequestStatus `json:"status" gorm:"default:PENDING"`
|
||||
// Full request made by user (arr.SonarrRequest / arr.RadarrRequest)
|
||||
// so we know how to fulfil the request if approved.
|
||||
RequestJson string `json:"requestJson"`
|
||||
}
|
||||
|
||||
func deleteArrRequest(db *gorm.DB, id uint) error {
|
||||
resp := db.Delete(&ArrRequest{ID: id})
|
||||
resp := db.Delete(&entity.ArrRequest{ID: id})
|
||||
if resp.Error != nil {
|
||||
slog.Error("deleteArrRequest: Failed to remove from db", "error", resp.Error)
|
||||
return errors.New("failed when removing request")
|
||||
@@ -57,12 +22,12 @@ func deleteArrRequest(db *gorm.DB, id uint) error {
|
||||
}
|
||||
|
||||
// Gets all requests.
|
||||
func getArrRequests(db *gorm.DB) ([]ArrRequest, error) {
|
||||
var req []ArrRequest
|
||||
func getArrRequests(db *gorm.DB) ([]entity.ArrRequest, error) {
|
||||
var req []entity.ArrRequest
|
||||
resp := db.Preload("Content").Preload("User").Find(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequests: Failed to search for requests in db", "error", resp.Error)
|
||||
return []ArrRequest{}, errors.New("failed to find requests")
|
||||
return []entity.ArrRequest{}, errors.New("failed to find requests")
|
||||
}
|
||||
for i := range req {
|
||||
req[i].Username = req[i].User.Username
|
||||
@@ -70,57 +35,57 @@ func getArrRequests(db *gorm.DB) ([]ArrRequest, error) {
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getArrRequest(db *gorm.DB, requestId uint) (ArrRequest, error) {
|
||||
var req ArrRequest
|
||||
func getArrRequest(db *gorm.DB, requestId uint) (entity.ArrRequest, error) {
|
||||
var req entity.ArrRequest
|
||||
resp := db.Where("id = ?", requestId).Take(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequest: Failed to search for request in db", "error", resp.Error)
|
||||
return ArrRequest{}, errors.New("failed to find request")
|
||||
return entity.ArrRequest{}, errors.New("failed to find request")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func getArrRequestByTmdbId(db *gorm.DB, contentType ContentType, tmdbId int) (ArrRequest, error) {
|
||||
var req ArrRequest
|
||||
func getArrRequestByTmdbId(db *gorm.DB, contentType entity.ContentType, tmdbId int) (entity.ArrRequest, error) {
|
||||
var req entity.ArrRequest
|
||||
resp := db.Joins("JOIN contents ON contents.id = arr_requests.content_id AND contents.tmdb_id = ? AND contents.type = ?", tmdbId, contentType).Find(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getArrRequestByTmdbId: Failed to search for request in db", "error", resp.Error)
|
||||
return ArrRequest{}, errors.New("failed to find request")
|
||||
return entity.ArrRequest{}, errors.New("failed to find request")
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func createArrRequest(db *gorm.DB, userId uint, serverName string, contentType ContentType, tmdbId int, reqJson string) (*ArrRequest, error) {
|
||||
content, err := getOrCacheContent(db, contentType, tmdbId)
|
||||
func createArrRequest(db *gorm.DB, cp ContentProvider, userId uint, serverName string, contentType entity.ContentType, tmdbId int, reqJson string) (*entity.ArrRequest, error) {
|
||||
content, err := cp.GetOrCacheContent(db, contentType, tmdbId)
|
||||
if err != nil {
|
||||
slog.Error("createArrRequest: getOrCacheContent errored.")
|
||||
return &ArrRequest{}, err
|
||||
slog.Error("createArrRequest: GetOrCacheContent errored.")
|
||||
return &entity.ArrRequest{}, err
|
||||
}
|
||||
req := ArrRequest{UserID: userId, ServerName: serverName, ContentID: &content.ID, RequestJson: reqJson}
|
||||
req := entity.ArrRequest{UserID: userId, ServerName: serverName, ContentID: &content.ID, RequestJson: reqJson}
|
||||
resp := db.Create(&req)
|
||||
if resp.Error != nil {
|
||||
slog.Error("createArrRequest: Failed when inserting request into db.", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when adding request")
|
||||
return &entity.ArrRequest{}, errors.New("failed when adding request")
|
||||
}
|
||||
return &req, nil
|
||||
}
|
||||
|
||||
func createSonarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.SonarrRequest) (*ArrRequest, error) {
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
func createSonarrRequest(cfg *config.ServerConfig, db *gorm.DB, cp ContentProvider, userId uint, userPerms int, ur arr.SonarrRequest) (*entity.ArrRequest, error) {
|
||||
server, err := getSonarr(cfg, ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed to get server", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to get server")
|
||||
return &entity.ArrRequest{}, errors.New("failed to get server")
|
||||
}
|
||||
reqJson, err := json.Marshal(ur)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when processing request")
|
||||
return &entity.ArrRequest{}, errors.New("failed when processing request")
|
||||
}
|
||||
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
|
||||
arrReq, err := createArrRequest(db, userId, ur.ServerName, SHOW, ur.TMDBID, string(reqJson[:]))
|
||||
arrReq, err := createArrRequest(db, cp, userId, ur.ServerName, entity.SHOW, ur.TMDBID, string(reqJson[:]))
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed when creating arr request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when creating request")
|
||||
return &entity.ArrRequest{}, errors.New("failed when creating request")
|
||||
}
|
||||
sonarr := arr.New(arr.SONARR, &server.Host, &server.Key)
|
||||
// 1. Lookup on Sonarr to check if the show has already been added (via method other than watcharr).
|
||||
@@ -130,10 +95,14 @@ func createSonarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.SonarrR
|
||||
found := lookupRes[0] // There should only be one result when looking up by id.
|
||||
// If it has an ID, then it will have already been added to Sonarr.
|
||||
if found.ID != 0 {
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
|
||||
dbResp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", arrReq.ID).
|
||||
Update("arr_id", found.ID).
|
||||
Update("status", entity.ARR_REQUEST_FOUND)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
return &entity.ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
} else {
|
||||
slog.Debug("createSonarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
|
||||
arrReq.ArrID = found.ID
|
||||
@@ -142,46 +111,50 @@ func createSonarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.SonarrR
|
||||
}
|
||||
}
|
||||
// 2. If user has auto approve perms, add movie to sonarr.
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
if permission.Has(userPerms, entity.PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
slog.Debug("createSonarrRequest: User has auto approve permission.. sending request to Sonarr.")
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := sonarr.AddContent(sonarr.BuildAddShowBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("createSonarrRequest: Failed to add content", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to add content")
|
||||
return &entity.ArrRequest{}, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
|
||||
dbResp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", arrReq.ID).
|
||||
Update("arr_id", resp["id"]).
|
||||
Update("status", entity.ARR_REQUEST_AUTO_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createSonarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
return &entity.ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("createSonarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return &ArrRequest{}, errors.New("failed to get arr id")
|
||||
return &entity.ArrRequest{}, errors.New("failed to get arr id")
|
||||
}
|
||||
arrReq.ArrID = int(arrId)
|
||||
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
|
||||
arrReq.Status = entity.ARR_REQUEST_AUTO_APPROVED
|
||||
}
|
||||
return arrReq, nil
|
||||
}
|
||||
|
||||
func createRadarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.RadarrRequest) (*ArrRequest, error) {
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
func createRadarrRequest(cfg *config.ServerConfig, db *gorm.DB, cp ContentProvider, userId uint, userPerms int, ur arr.RadarrRequest) (*entity.ArrRequest, error) {
|
||||
server, err := getRadarr(cfg, ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed to get server", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to get server")
|
||||
return &entity.ArrRequest{}, errors.New("failed to get server")
|
||||
}
|
||||
reqJson, err := json.Marshal(ur)
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when marshalling json request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when processing request")
|
||||
return &entity.ArrRequest{}, errors.New("failed when processing request")
|
||||
}
|
||||
// Since we create the request in the db now, we don't have to check for duplicates, a unique constraint will error us here if hit.
|
||||
arrReq, err := createArrRequest(db, userId, ur.ServerName, MOVIE, ur.TMDBID, string(reqJson[:]))
|
||||
arrReq, err := createArrRequest(db, cp, userId, ur.ServerName, entity.MOVIE, ur.TMDBID, string(reqJson[:]))
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed when creating arr request", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed when creating request")
|
||||
return &entity.ArrRequest{}, errors.New("failed when creating request")
|
||||
}
|
||||
radarr := arr.New(arr.RADARR, &server.Host, &server.Key)
|
||||
// 1. Lookup on Radarr to check if the movie has already been added (via method other than watcharr).
|
||||
@@ -191,10 +164,14 @@ func createRadarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.RadarrR
|
||||
found := lookupRes[0] // There should only be one result when looking up by id.
|
||||
// If it has an ID, then it will have already been added to Radarr.
|
||||
if found.ID != 0 {
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", found.ID).Update("status", ARR_REQUEST_FOUND)
|
||||
dbResp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", arrReq.ID).
|
||||
Update("arr_id", found.ID).
|
||||
Update("status", entity.ARR_REQUEST_FOUND)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
return &entity.ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
} else {
|
||||
slog.Debug("createRadarrRequest: Result from lookup had an ID. Request in database has been updated with it.", "arr_id", found.ID)
|
||||
arrReq.ArrID = found.ID
|
||||
@@ -203,31 +180,35 @@ func createRadarrRequest(db *gorm.DB, userId uint, userPerms int, ur arr.RadarrR
|
||||
}
|
||||
}
|
||||
// 2. If user has auto approve perms, add movie to radarr.
|
||||
if hasPermission(userPerms, PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
if permission.Has(userPerms, entity.PERM_REQUEST_CONTENT_AUTO_APPROVE) {
|
||||
slog.Debug("createRadarrRequest: User has auto approve permission.. sending request to Radarr.")
|
||||
ur.AutomaticSearch = server.AutomaticSearch
|
||||
resp, err := radarr.AddContent(radarr.BuildAddMovieBody(ur))
|
||||
if err != nil {
|
||||
slog.Error("createRadarrRequest: Failed to add content", "error", err)
|
||||
return &ArrRequest{}, errors.New("failed to add content")
|
||||
return &entity.ArrRequest{}, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", arrReq.ID).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_AUTO_APPROVED)
|
||||
dbResp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", arrReq.ID).
|
||||
Update("arr_id", resp["id"]).
|
||||
Update("status", entity.ARR_REQUEST_AUTO_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("createRadarrRequest: Failed to update request in db", "error", err)
|
||||
return &ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
return &entity.ArrRequest{}, errors.New("content was requested, but we failed to update the db")
|
||||
}
|
||||
arrId, ok := resp["id"].(float64)
|
||||
if !ok {
|
||||
slog.Error("createRadarrRequest: Failed to cast arr id as an int", "id", resp["id"])
|
||||
return &ArrRequest{}, errors.New("failed to get arr id")
|
||||
return &entity.ArrRequest{}, errors.New("failed to get arr id")
|
||||
}
|
||||
arrReq.ArrID = int(arrId)
|
||||
arrReq.Status = ARR_REQUEST_AUTO_APPROVED
|
||||
arrReq.Status = entity.ARR_REQUEST_AUTO_APPROVED
|
||||
}
|
||||
return arrReq, nil
|
||||
}
|
||||
|
||||
func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
func getRadarrRequestInfo(cfg *config.ServerConfig, db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
if requestId == 0 {
|
||||
slog.Error("sonarr info: No request id provided")
|
||||
return arr.MovieSerie{}, errors.New("no request id provided")
|
||||
@@ -237,7 +218,7 @@ func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
slog.Error("radarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
server, err := getRadarr(arrRequest.ServerName)
|
||||
server, err := getRadarr(cfg, arrRequest.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
@@ -246,7 +227,7 @@ func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
resp, respStatusCode, err := radarr.GetContent(arrRequest.ArrID)
|
||||
if err != nil {
|
||||
slog.Error("radarr info: Failed to get info", "error", err)
|
||||
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
if (arrRequest.Status == entity.ARR_REQUEST_APPROVED || arrRequest.Status == entity.ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
slog.Error("radarr info: 404 returned.. content must've been removed.. removing request.")
|
||||
err := deleteArrRequest(db, arrRequest.ID)
|
||||
if err != nil {
|
||||
@@ -260,7 +241,7 @@ func getRadarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func getSonarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
func getSonarrRequestInfo(cfg *config.ServerConfig, db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
if requestId == 0 {
|
||||
slog.Error("sonarr info: No request id provided")
|
||||
return arr.MovieSerie{}, errors.New("no request id provided")
|
||||
@@ -270,7 +251,7 @@ func getSonarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
slog.Error("sonarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
}
|
||||
server, err := getSonarr(arrRequest.ServerName)
|
||||
server, err := getSonarr(cfg, arrRequest.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get server", "error", err)
|
||||
return arr.MovieSerie{}, errors.New("failed to get server")
|
||||
@@ -279,7 +260,7 @@ func getSonarrRequestInfo(db *gorm.DB, requestId uint) (arr.MovieSerie, error) {
|
||||
resp, respStatusCode, err := sonarr.GetContent(arrRequest.ArrID)
|
||||
if err != nil {
|
||||
slog.Error("sonarr info: Failed to get info", "error", err)
|
||||
if (arrRequest.Status == ARR_REQUEST_APPROVED || arrRequest.Status == ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
if (arrRequest.Status == entity.ARR_REQUEST_APPROVED || arrRequest.Status == entity.ARR_REQUEST_AUTO_APPROVED) && respStatusCode == 404 {
|
||||
slog.Error("sonarr info: 404 returned.. content must've been removed.. removing request.")
|
||||
err := deleteArrRequest(db, arrRequest.ID)
|
||||
if err != nil {
|
||||
@@ -1,16 +1,21 @@
|
||||
package main
|
||||
package arr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Deny an arr request
|
||||
func denyArrRequest(db *gorm.DB, id uint) error {
|
||||
resp := db.Model(&ArrRequest{}).Where("id = ?", id).Update("status", ARR_REQUEST_DENIED)
|
||||
resp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", entity.ARR_REQUEST_DENIED)
|
||||
if resp.Error != nil {
|
||||
slog.Error("denyArrRequest: Failed to update status to denied", "error", resp.Error)
|
||||
return errors.New("failed when updating request status")
|
||||
@@ -19,14 +24,14 @@ func denyArrRequest(db *gorm.DB, id uint) error {
|
||||
}
|
||||
|
||||
// Approve radarr movie
|
||||
func approveRadarrRequest(db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, error) {
|
||||
func approveRadarrRequest(cfg *config.ServerConfig, db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, error) {
|
||||
_, err := getArrRequest(db, reqId)
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to get request from db", "error", err)
|
||||
return 0, errors.New("failed to get request")
|
||||
}
|
||||
// Get server in request
|
||||
server, err := getRadarr(ur.ServerName)
|
||||
server, err := getRadarr(cfg, ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to get server", "error", err)
|
||||
return 0, errors.New("failed to get server")
|
||||
@@ -38,7 +43,11 @@ func approveRadarrRequest(db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, e
|
||||
slog.Error("approveRadarrRequest: Failed to add content", "error", err)
|
||||
return 0, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
|
||||
dbResp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", reqId).
|
||||
Update("arr_id", resp["id"]).
|
||||
Update("status", entity.ARR_REQUEST_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("approveRadarrRequest: Failed to update request in db", "error", err)
|
||||
return 0, errors.New("content was requested, but we failed to update the db")
|
||||
@@ -52,14 +61,14 @@ func approveRadarrRequest(db *gorm.DB, reqId uint, ur arr.RadarrRequest) (int, e
|
||||
}
|
||||
|
||||
// Approve sonarr movie
|
||||
func approveSonarrRequest(db *gorm.DB, reqId uint, ur arr.SonarrRequest) (int, error) {
|
||||
func approveSonarrRequest(cfg *config.ServerConfig, db *gorm.DB, reqId uint, ur arr.SonarrRequest) (int, error) {
|
||||
_, err := getArrRequest(db, reqId)
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to get request from db", "error", err)
|
||||
return 0, errors.New("failed to get request")
|
||||
}
|
||||
// Get server in request
|
||||
server, err := getSonarr(ur.ServerName)
|
||||
server, err := getSonarr(cfg, ur.ServerName)
|
||||
if err != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to get server", "error", err)
|
||||
return 0, errors.New("failed to get server")
|
||||
@@ -71,7 +80,11 @@ func approveSonarrRequest(db *gorm.DB, reqId uint, ur arr.SonarrRequest) (int, e
|
||||
slog.Error("approveSonarrRequest: Failed to add content", "error", err)
|
||||
return 0, errors.New("failed to add content")
|
||||
}
|
||||
dbResp := db.Model(&ArrRequest{}).Where("id = ?", reqId).Update("arr_id", resp["id"]).Update("status", ARR_REQUEST_APPROVED)
|
||||
dbResp := db.
|
||||
Model(&entity.ArrRequest{}).
|
||||
Where("id = ?", reqId).
|
||||
Update("arr_id", resp["id"]).
|
||||
Update("status", entity.ARR_REQUEST_APPROVED)
|
||||
if dbResp.Error != nil {
|
||||
slog.Error("approveSonarrRequest: Failed to update request in db", "error", err)
|
||||
return 0, errors.New("content was requested, but we failed to update the db")
|
||||
@@ -0,0 +1,443 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/arr"
|
||||
"github.com/sbondCo/Watcharr/config/cfgmodel"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// ContentProvider - Temporary, this ARR code at some point will
|
||||
// be turned into services to conform with new code format, for now
|
||||
// passing contentprovider through here.
|
||||
type ContentProvider interface {
|
||||
GetOrCacheContent(db *gorm.DB, contentType entity.ContentType, tmdbId int) (entity.Content, error)
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
contentProvider ContentProvider
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
// **NOTE:** Routes are manually given authmiddleware.AdminRequired or authmiddleware.PermRequired middleware.
|
||||
|
||||
// SONARR
|
||||
{
|
||||
s := r.br.Router.Group("/arr/son").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg))
|
||||
|
||||
// Routes are manually given authmiddleware.AdminRequired or authmiddleware.PermRequired middleware.
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", authmiddleware.AdminRequired(), r.TestSonarr)
|
||||
// Used to get config for specific server (quality profile, root folder, etc)
|
||||
s.GET("/config/:name", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetSonarrServer)
|
||||
// Add sonarr server into config
|
||||
s.POST("/add", authmiddleware.AdminRequired(), r.AddSonarr)
|
||||
// Edit sonarr servers config
|
||||
s.POST("/edit", authmiddleware.AdminRequired(), r.UpdateSonarrServer)
|
||||
// Remove sonarr server
|
||||
s.POST("/rm/:name", authmiddleware.AdminRequired(), r.UpdateRemoveSonarrServer)
|
||||
// Get safe config for all sonarr servers
|
||||
s.GET("", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetSonarrsSafe)
|
||||
// Request a show
|
||||
s.POST("/request", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.CreateSonarrRequest)
|
||||
s.GET("/request/:tmdbId", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetSonarrRequestByTmdbId)
|
||||
s.POST("/request/approve/:id", authmiddleware.PermRequired(entity.PERM_ADMIN), r.UpdateApproveSonarrRequest)
|
||||
s.GET("/status/:serverName/:arrId", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetSonarrQueueDetails)
|
||||
s.GET("/info/:requestId", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetSonarrRequestInfo)
|
||||
}
|
||||
|
||||
// RADARR
|
||||
{
|
||||
s := r.br.Router.Group("/arr/rad").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg))
|
||||
|
||||
// Routes are manually given authmiddleware.AdminRequired or authmiddleware.PermRequired middleware.
|
||||
|
||||
// Test configuration
|
||||
s.POST("/test", authmiddleware.AdminRequired(), r.TestRadarr)
|
||||
// Get config for specific server
|
||||
s.GET("/config/:name", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetRadarrServer)
|
||||
s.POST("/add", authmiddleware.AdminRequired(), r.AddRadarr)
|
||||
s.POST("/edit", authmiddleware.AdminRequired(), r.UpdateRadarrServer)
|
||||
s.POST("/rm/:name", authmiddleware.AdminRequired(), r.UpdateRemoveRadarrServer)
|
||||
s.GET("", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetRadarrsSafe)
|
||||
s.POST("/request", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.CreateRadarrRequest)
|
||||
s.GET("/request/:tmdbId", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetRadarrRequestByTmdbId)
|
||||
s.POST("/request/approve/:id", authmiddleware.PermRequired(entity.PERM_ADMIN), r.UpdateApproveRadarrRequest)
|
||||
s.GET("/status/:serverName/:arrId", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetRadarrQueueDetails)
|
||||
s.GET("/info/:requestId", authmiddleware.PermRequired(entity.PERM_REQUEST_CONTENT), r.GetRadarrRequestInfo)
|
||||
}
|
||||
|
||||
// Request Management
|
||||
{
|
||||
s := r.br.Router.Group("/arr/request").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg))
|
||||
|
||||
// Get all requests (for manage_requests view), only for admins.
|
||||
s.GET("/", authmiddleware.AdminRequired(), r.GetAllRequests)
|
||||
// Deny a request (for manage_requests view), only for admins.
|
||||
s.POST("/deny/:id", authmiddleware.AdminRequired(), r.UpdateDenyRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// Test configuration
|
||||
func (r *Router) TestSonarr(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := testSonarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Used to get config for specific server (quality profile, root folder, etc)
|
||||
func (r *Router) GetSonarrServer(c *gin.Context) {
|
||||
server, err := getSonarr(r.br.Cfg, c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := testSonarr(ArrTestParams{Host: server.Host, Key: server.Key})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Add sonarr server into config
|
||||
func (r *Router) AddSonarr(c *gin.Context) {
|
||||
var ur cfgmodel.SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := addSonarr(r.br.Cfg, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Edit sonarr servers config
|
||||
func (r *Router) UpdateSonarrServer(c *gin.Context) {
|
||||
var ur cfgmodel.SonarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := editSonarr(r.br.Cfg, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Remove sonarr server
|
||||
func (r *Router) UpdateRemoveSonarrServer(c *gin.Context) {
|
||||
err := rmSonarr(r.br.Cfg, c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
// Get safe config for all sonarr servers
|
||||
func (r *Router) GetSonarrsSafe(c *gin.Context) {
|
||||
response := getSonarrsSafe(r.br.Cfg)
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Request a show
|
||||
func (r *Router) CreateSonarrRequest(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
perms := c.GetInt("userPermissions")
|
||||
response, err := createSonarrRequest(r.br.Cfg, r.br.DB, r.contentProvider, userId, perms, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) GetSonarrRequestByTmdbId(c *gin.Context) {
|
||||
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getArrRequestByTmdbId(r.br.DB, entity.SHOW, tmdbId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) UpdateApproveSonarrRequest(c *gin.Context) {
|
||||
var ur arr.SonarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := approveSonarrRequest(r.br.Cfg, r.br.DB, uint(requestId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) GetSonarrQueueDetails(c *gin.Context) {
|
||||
response, err := getSonarrQueueDetails(r.br.Cfg, c.Param("serverName"), c.Param("arrId"))
|
||||
if err != nil {
|
||||
if err.Error() == "no details found" {
|
||||
c.Status(http.StatusNoContent) // Item not found in queue.. missing
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) GetSonarrRequestInfo(c *gin.Context) {
|
||||
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := getSonarrRequestInfo(r.br.Cfg, r.br.DB, uint(requestId))
|
||||
if err != nil {
|
||||
if err.Error() == "request deleted" {
|
||||
c.JSON(http.StatusNotFound, router.ErrorResponse{Error: "request deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Test configuration
|
||||
func (r *Router) TestRadarr(c *gin.Context) {
|
||||
var ur ArrTestParams
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := testRadarr(ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Get config for specific server
|
||||
func (r *Router) GetRadarrServer(c *gin.Context) {
|
||||
server, err := getRadarr(r.br.Cfg, c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
resp, err := testRadarr(ArrTestParams{Host: server.Host, Key: server.Key})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func (r *Router) AddRadarr(c *gin.Context) {
|
||||
var ur cfgmodel.RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := addRadarr(r.br.Cfg, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) UpdateRadarrServer(c *gin.Context) {
|
||||
var ur cfgmodel.RadarrSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := editRadarr(r.br.Cfg, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) UpdateRemoveRadarrServer(c *gin.Context) {
|
||||
err := rmRadarr(r.br.Cfg, c.Param("name"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (r *Router) GetRadarrsSafe(c *gin.Context) {
|
||||
response := getRadarrsSafe(r.br.Cfg)
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) CreateRadarrRequest(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
perms := c.GetInt("userPermissions")
|
||||
response, err := createRadarrRequest(r.br.Cfg, r.br.DB, r.contentProvider, userId, perms, ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) GetRadarrRequestByTmdbId(c *gin.Context) {
|
||||
tmdbId, err := strconv.Atoi(c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse tmdbId", "tmdbId", tmdbId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getArrRequestByTmdbId(r.br.DB, entity.MOVIE, tmdbId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) UpdateApproveRadarrRequest(c *gin.Context) {
|
||||
var ur arr.RadarrRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := approveRadarrRequest(r.br.Cfg, r.br.DB, uint(requestId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) GetRadarrQueueDetails(c *gin.Context) {
|
||||
response, err := getRadarrQueueDetails(r.br.Cfg, c.Param("serverName"), c.Param("arrId"))
|
||||
if err != nil {
|
||||
if err.Error() == "no details found" {
|
||||
c.Status(http.StatusNoContent) // Item not found in queue.. missing
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) GetRadarrRequestInfo(c *gin.Context) {
|
||||
requestId, err := strconv.ParseUint(c.Param("requestId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/info/:requestId - requestId could not be parsed", "requestId", requestId)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
response, err := getRadarrRequestInfo(r.br.Cfg, r.br.DB, uint(requestId))
|
||||
if err != nil {
|
||||
if err.Error() == "request deleted" {
|
||||
c.JSON(http.StatusNotFound, router.ErrorResponse{Error: "request deleted"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Get all requests (for manage_requests view), only for admins.
|
||||
func (r *Router) GetAllRequests(c *gin.Context) {
|
||||
response, err := getArrRequests(r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Deny a request (for manage_requests view), only for admins.
|
||||
func (r *Router) UpdateDenyRequest(c *gin.Context) {
|
||||
requestId, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("Couldn't parse request id", "request_id", requestId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
err = denyArrRequest(r.br.DB, uint(requestId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -17,117 +17,15 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/plex"
|
||||
"github.com/sbondCo/Watcharr/token"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserType uint8
|
||||
|
||||
var (
|
||||
WATCHARR_USER UserType = 0
|
||||
JELLYFIN_USER UserType = 1
|
||||
PLEX_USER UserType = 2
|
||||
// Registered via trusted header auth
|
||||
PROXY_USER UserType = 3
|
||||
)
|
||||
|
||||
// User Perms
|
||||
// iota auto increments for us so when adding new
|
||||
// perms, add to bottom as to not change other perm
|
||||
// values.
|
||||
const (
|
||||
PERM_NONE int = 1 << iota
|
||||
PERM_ADMIN
|
||||
PERM_REQUEST_CONTENT
|
||||
PERM_REQUEST_CONTENT_AUTO_APPROVE
|
||||
)
|
||||
|
||||
// uniqueIndex applied between Username and UserType, so same usernames can exist, but only with different types.
|
||||
// This is incase different users with same name from different services try to signup.
|
||||
type User struct {
|
||||
GormModel
|
||||
Username string `gorm:"uniqueIndex:usr_name_to_type;not null" json:"username" binding:"required"`
|
||||
Password string `gorm:"not null" json:"password" binding:"required"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
// The type of user/which auth service they originate from.
|
||||
// Empty if from Watcharr, or the name of the service (eg. jellyfin)
|
||||
Type UserType `gorm:"uniqueIndex:usr_name_to_type;not null;default:0" json:"type"`
|
||||
// ID of user from the third party service, this will be used purely for lookup of user at signin.
|
||||
ThirdPartyID string `json:"-"`
|
||||
// Auth token from third party (jellyfin)
|
||||
ThirdPartyAuth string `json:"-"`
|
||||
// Users third party integrations (minus jellyfin for now)
|
||||
UserServices []UserServices `json:"-"`
|
||||
Watched []Watched
|
||||
// All Tags
|
||||
Tags []Tag `json:"-"`
|
||||
// Users permissions
|
||||
Permissions int `gorm:"default:1" json:"-"`
|
||||
// All user settings cols, in another struct for reusability
|
||||
UserSettings
|
||||
}
|
||||
|
||||
func (u *User) GetSafe() PublicUser {
|
||||
return PublicUser{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Avatar: u.Avatar,
|
||||
Bio: u.Bio,
|
||||
}
|
||||
}
|
||||
|
||||
// This struct uses pointer to the values, so in update user settings,
|
||||
// we can tell which setting is being updated (if not nil..).
|
||||
type UserSettings struct {
|
||||
// Is profile private
|
||||
Private *bool `gorm:"default:false" json:"private"`
|
||||
// Are watched list content thoughts public (profile must also be public is false)
|
||||
PrivateThoughts *bool `gorm:"default:false" json:"privateThoughts"`
|
||||
// If ui 'spoilers' should be shown
|
||||
HideSpoilers *bool `gorm:"default:false" json:"hideSpoilers"`
|
||||
// If user wants previously watched items to show in 'Finished' filter,
|
||||
// even if the watched item state has since been changed.
|
||||
// Also if user wants to show in watched stats.
|
||||
IncludePreviouslyWatched *bool `gorm:"default:false" json:"includePreviouslyWatched"`
|
||||
// User's country to get correct content streaming providers.
|
||||
Country *string `gorm:"default:'US'" json:"country"`
|
||||
// Does the user want show, season and episode automations enabled.
|
||||
AutomateShowStatuses *bool `gorm:"default:true" json:"automateShowStatuses"`
|
||||
// Rating system user wants to use (frontend only).
|
||||
// RatingSystem enum in frontend maxes out at 3, so just max=3 on this and we should be gut.
|
||||
RatingSystem *int `json:"ratingSystem" binding:"omitempty,max=3"`
|
||||
// Rating step for supported rating systems (frontend only, enum goes up to 2).
|
||||
RatingStep *int `json:"ratingStep" binding:"omitempty,max=2"`
|
||||
}
|
||||
|
||||
// Holds third party service auth tokens for users.
|
||||
// Each service may use the fields in their own way.
|
||||
// Unique index applied between service name and clientID
|
||||
// to ensure no duplicates (no need to apply it against
|
||||
// user_id, no accounts should share an integration).
|
||||
//
|
||||
// Plex:
|
||||
// - AuthToken : Used for requests against plex.tv
|
||||
// - AuthToken2 : Used for requests against home plex server.
|
||||
type UserServices struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
// Service/integration name
|
||||
Name string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
// The users id on the third party service
|
||||
ClientID string `gorm:"uniqueIndex:svc_name_to_cltid;not null;" json:"-"`
|
||||
AuthToken string `gorm:"not null;" json:"-"`
|
||||
// Second auth token, generic name so future services can use it without extra confusion.
|
||||
// Ex: We require a second auth token for use with our local server for Plex.
|
||||
AuthToken2 string `json:"-"`
|
||||
UserID uint `gorm:"not null;" json:"-"`
|
||||
}
|
||||
|
||||
// We use a separate struct for registration to avoid confusion
|
||||
// and possible accidents where we allow a user to pass in a
|
||||
// property from the main User struct that shouldn't be allowed.
|
||||
@@ -157,6 +55,11 @@ type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type UserPasswordUpdateRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
type AvailableAuthProvidersResponse struct {
|
||||
AvailableAuthProviders []string `json:"available"`
|
||||
SignupEnabled bool `json:"signupEnabled"`
|
||||
@@ -165,134 +68,28 @@ type AvailableAuthProvidersResponse struct {
|
||||
HeaderAuthAutoLogin bool `json:"headerAuthAutoLogin"`
|
||||
}
|
||||
|
||||
type ArgonParams struct {
|
||||
memory uint32
|
||||
iterations uint32
|
||||
parallelism uint8
|
||||
saltLength uint32
|
||||
keyLength uint32
|
||||
type PlexProvider interface {
|
||||
FetchPlexAccountFromToken(token string) (plex.PlexUser, error)
|
||||
GetPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error)
|
||||
}
|
||||
|
||||
func GetPassArgonParams() *ArgonParams {
|
||||
return &ArgonParams{
|
||||
memory: 64 * 1024,
|
||||
iterations: 3,
|
||||
parallelism: 2,
|
||||
saltLength: 16,
|
||||
keyLength: 32,
|
||||
}
|
||||
type Service struct {
|
||||
cfg *config.ServerConfig
|
||||
plexProvider PlexProvider
|
||||
}
|
||||
|
||||
type TokenClaims struct {
|
||||
UserID uint `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
jwt.RegisteredClaims
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
type UserPasswordUpdateRequest struct {
|
||||
OldPassword string `json:"oldPassword" binding:"required"`
|
||||
NewPassword string `json:"newPassword" binding:"required"`
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
func AuthRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("AuthRequired middleware hit")
|
||||
atoken := c.GetHeader("Authorization")
|
||||
// Make sure auth header isn't empty
|
||||
if atoken == "" {
|
||||
slog.Warn("Returning 401, Authorization header not provided")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
// Parse token
|
||||
token, err := jwt.ParseWithClaims(atoken, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(Config.JWT_SECRET), nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("AuthRequired failed to parse token", "error", err)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
// If token is valid, go to next handler
|
||||
if claims, ok := token.Claims.(*TokenClaims); ok && token.Valid {
|
||||
// Check if token issuedAt is from before `timeOfNewLoginRequired`.
|
||||
// Basically just so we can logout old tokens and force relogin...
|
||||
// since new changes require the user login again.
|
||||
timeOfNewLoginRequired, _ := time.Parse(time.RFC822, "18 Aug 23 20:30 UTC")
|
||||
if claims.IssuedAt.Before(timeOfNewLoginRequired) {
|
||||
slog.Info("Token is from before timeOfNewLoginRequired.. returning 401", "token_issued_at", claims.IssuedAt, "time_of_new_login_required", timeOfNewLoginRequired)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("Token is valid", "claims", claims)
|
||||
c.Set("userId", claims.UserID)
|
||||
c.Set("userType", claims.Type)
|
||||
// If db passed, get extra user info and set as variables in req context
|
||||
if db != nil {
|
||||
slog.Debug("AuthRequired: db passed.. getting extra user info")
|
||||
dbUser := new(User)
|
||||
res := db.Where("id = ?", claims.UserID).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("AuthRequired: Failed to select user from database", "error", res.Error)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("AuthRequired: fetched extra user info. Setting vars.", "userThirdPartyId", dbUser.ThirdPartyID, "userThirdPartyAuth", "lol this is censored dude")
|
||||
c.Set("userThirdPartyId", dbUser.ThirdPartyID)
|
||||
c.Set("userThirdPartyAuth", dbUser.ThirdPartyAuth)
|
||||
c.Set("username", dbUser.Username)
|
||||
c.Set("userPermissions", dbUser.Permissions)
|
||||
}
|
||||
c.Next()
|
||||
} else {
|
||||
slog.Error("Token is **not** valid")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin only middleware (use after AuthRequired with extra info!)
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if hasPermission(perms, PERM_ADMIN) {
|
||||
slog.Debug("AdminRequired: User has permission to access admin only route", "user_id", userId)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("AdminRequired: User denied permission to access admin only route", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
// Specific perm only middleware (use after AuthRequired with extra info!)
|
||||
func PermRequired(perm int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if hasPermission(perms, perm) {
|
||||
slog.Debug("PermRequired: User has permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("PermRequired: User denied permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
|
||||
if !Config.SIGNUP_ENABLED {
|
||||
slog.Warn("Register called, but signing up is disabled.")
|
||||
func (s *Service) Register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthResponse, error) {
|
||||
if !s.cfg.SIGNUP_ENABLED {
|
||||
slog.Warn("Register: Register called, but signing up is disabled.")
|
||||
return AuthResponse{}, errors.New("registering is disabled")
|
||||
}
|
||||
var user User = User{Username: ur.Username, Password: ur.Password}
|
||||
slog.Info("A user is registering", "username", user.Username)
|
||||
hash, err := hashPassword(user.Password, GetPassArgonParams())
|
||||
var user entity.User = entity.User{Username: ur.Username, Password: ur.Password}
|
||||
slog.Info("Register: A user is registering", "username", user.Username)
|
||||
hash, err := s.hashPassword(user.Password, entity.GetPassArgonParams())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
@@ -301,12 +98,12 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
user.Password = hash
|
||||
|
||||
// Update user permissions if an initial perm is passed in (1 is default)
|
||||
if initialPerm != 0 && initialPerm != PERM_NONE {
|
||||
slog.Info("User being registered has been given extra initial permissions", "initial_perm", initialPerm)
|
||||
if initialPerm != 0 && initialPerm != entity.PERM_NONE {
|
||||
slog.Info("Register: User being registered has been given extra initial permissions", "initial_perm", initialPerm)
|
||||
user.Permissions = initialPerm
|
||||
}
|
||||
|
||||
user.Country = &Config.DEFAULT_COUNTRY
|
||||
user.Country = &s.cfg.DEFAULT_COUNTRY
|
||||
|
||||
res := db.Create(&user)
|
||||
if res.Error != nil {
|
||||
@@ -326,7 +123,7 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
return AuthResponse{}, errors.New("failed to get user id, try login")
|
||||
}
|
||||
|
||||
token, err := signJWT(&user)
|
||||
token, err := s.signJWT(&user)
|
||||
if err != nil {
|
||||
slog.Error("Registration: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
@@ -334,10 +131,10 @@ func register(ur *UserRegisterRequest, initialPerm int, db *gorm.DB) (AuthRespon
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func registerFirstUser(user *UserRegisterRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
func (s *Service) RegisterFirstUser(urr *UserRegisterRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
// Ensure no users exist
|
||||
var userCount int64
|
||||
uresp := db.Model(&User{}).Count(&userCount)
|
||||
uresp := db.Model(&entity.User{}).Count(&userCount)
|
||||
if uresp.Error != nil {
|
||||
slog.Error("registerFirstUser: User count query failed!", "error", uresp.Error)
|
||||
return AuthResponse{}, errors.New("failed to query db for a count of users")
|
||||
@@ -347,19 +144,19 @@ func registerFirstUser(user *UserRegisterRequest, db *gorm.DB) (AuthResponse, er
|
||||
return AuthResponse{}, errors.New("first user already registered")
|
||||
}
|
||||
slog.Info("Registering first user.")
|
||||
return register(user, PERM_ADMIN, db)
|
||||
return s.Register(urr, entity.PERM_ADMIN, db)
|
||||
}
|
||||
|
||||
func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Debug("A User Is Logging In", "username", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND (type IS NULL OR type = 0)", user.Username).Take(&dbUser)
|
||||
func (s *Service) Login(userL *entity.User, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Debug("A User Is Logging In", "username", userL.Username)
|
||||
dbUser := new(entity.User)
|
||||
res := db.Where("username = ? AND (type IS NULL OR type = 0)", userL.Username).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to select user from database for login", "error", res.Error)
|
||||
return AuthResponse{}, errors.New("User does not exist")
|
||||
}
|
||||
|
||||
match, err := compareHash(user.Password, dbUser.Password)
|
||||
match, err := s.compareHash(userL.Password, dbUser.Password)
|
||||
if err != nil {
|
||||
slog.Error("Failed to compare pass to hash for login", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to login")
|
||||
@@ -369,7 +166,7 @@ func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{}, errors.New("incorrect details")
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
token, err := s.signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
@@ -377,20 +174,20 @@ func login(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
func (s *Service) LoginJellyfin(userL *entity.User, db *gorm.DB) (AuthResponse, error) {
|
||||
if s.cfg.JELLYFIN_HOST == "" {
|
||||
slog.Error("Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
return AuthResponse{}, errors.New("jellyfin login not enabled")
|
||||
}
|
||||
|
||||
base, err := url.Parse(Config.JELLYFIN_HOST + "/Users/AuthenticateByName")
|
||||
base, err := url.Parse(s.cfg.JELLYFIN_HOST + "/Users/AuthenticateByName")
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse AuthenticateByName api endpoint url", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Marshall struct as json
|
||||
usrJSON, err := json.Marshal(JellyfinAuth{Username: user.Username, Pw: user.Password})
|
||||
usrJSON, err := json.Marshal(JellyfinAuth{Username: userL.Username, Pw: userL.Password})
|
||||
if err != nil {
|
||||
slog.Error("Error marshalling JellyfinAuth JSON", "error", err.Error())
|
||||
return AuthResponse{}, errors.New("failed to marshal json")
|
||||
@@ -403,7 +200,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{}, errors.New("request failed")
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
req.Header.Add("X-Emby-Authorization", "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor"+user.Username+"\", Version=\"10.8.0\"")
|
||||
req.Header.Add("X-Emby-Authorization", "MediaBrowser Client=\"Watcharr\", Device=\"HTTP\", DeviceId=\"WatcharrFor"+userL.Username+"\", Version=\"10.8.0\"")
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
slog.Error("making request to jellyfin for auth failed", "error", err)
|
||||
@@ -429,8 +226,8 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{}, errors.New("jellyfin returned empty user id")
|
||||
}
|
||||
|
||||
dbUser := new(User)
|
||||
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, JELLYFIN_USER).Take(&dbUser)
|
||||
dbUser := new(entity.User)
|
||||
dbRes := db.Where("third_party_id = ? AND type = ?", resp.User.ID, entity.JELLYFIN_USER).Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
// Record not found, so we should create the user
|
||||
@@ -438,8 +235,8 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
dbUser.ThirdPartyID = resp.User.ID
|
||||
dbUser.ThirdPartyAuth = resp.AccessToken
|
||||
dbUser.Username = resp.User.Name
|
||||
dbUser.Type = JELLYFIN_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
dbUser.Type = entity.JELLYFIN_USER
|
||||
dbUser.Country = &s.cfg.DEFAULT_COUNTRY
|
||||
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
@@ -457,7 +254,7 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
db.Save(&dbUser)
|
||||
}
|
||||
|
||||
token, err := signJWT(dbUser)
|
||||
token, err := s.signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("Failed to sign new (jellyfin login) jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
@@ -466,13 +263,13 @@ func loginJellyfin(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
|
||||
// Login via Plex.
|
||||
func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
func (s *Service) LoginPlex(lr *plex.PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
if s.cfg.PLEX_HOST == "" || s.cfg.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("Request made to login via Plex, but Plex authentication is disabled")
|
||||
return AuthResponse{}, errors.New("plex login not enabled")
|
||||
}
|
||||
slog.Debug("A Plex User Is Logging In")
|
||||
account, err := fetchPlexAccountFromToken(lr.AuthToken)
|
||||
account, err := s.plexProvider.FetchPlexAccountFromToken(lr.AuthToken)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Could not fetch Plex account", "error", err)
|
||||
return AuthResponse{}, errors.New("could not fetch plex acount")
|
||||
@@ -483,26 +280,26 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
// Get users auth token against our home plex server.
|
||||
// If no auth token, assume they don't have access to our plex server.
|
||||
homeAuthToken, err := getPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
homeAuthToken, err := s.plexProvider.GetPlexHomeServerAuthToken(lr.AuthToken, lr.ClientIdentifier)
|
||||
if err != nil || homeAuthToken == "" {
|
||||
slog.Error("loginPlex: Failed to get home server auth token for user! If not because the request failed, then ensure the user has access to our home servers library.", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to verify plex access")
|
||||
}
|
||||
dbUser := new(User)
|
||||
dbUser := new(entity.User)
|
||||
userIdQ := db.Select("user_id").Where("name = ? AND client_id = ?", "plex", account.Id).Table("user_services")
|
||||
dbRes := db.Where("type = ?", PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
|
||||
dbRes := db.Where("type = ?", entity.PLEX_USER).Where("id = (?)", userIdQ).Preload("UserServices").Take(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
if errors.Is(dbRes.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Debug("loginPlex: New plex user attempted login.. creating Watcharr account now.")
|
||||
dbUser.Username = account.Username
|
||||
dbUser.Type = PLEX_USER
|
||||
dbUser.UserServices = append(dbUser.UserServices, UserServices{
|
||||
dbUser.Type = entity.PLEX_USER
|
||||
dbUser.UserServices = append(dbUser.UserServices, entity.UserServices{
|
||||
Name: "plex",
|
||||
ClientID: strconv.FormatUint(account.Id, 10),
|
||||
AuthToken: lr.AuthToken,
|
||||
AuthToken2: homeAuthToken,
|
||||
})
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
dbUser.Country = &s.cfg.DEFAULT_COUNTRY
|
||||
dbRes = db.Create(&dbUser)
|
||||
if dbRes.Error != nil {
|
||||
slog.Error("loginPlex: Failed to create new user in db from plex response", "error", dbRes.Error)
|
||||
@@ -526,7 +323,7 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
}
|
||||
db.Save(&dbUser.UserServices)
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
token, err := s.signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginPlex: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
@@ -534,19 +331,20 @@ func loginPlex(lr *PlexLoginRequest, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{Token: token}, nil
|
||||
}
|
||||
|
||||
func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
var dbToken Token
|
||||
// TODO the logic that gets and validated a token should be moved to Token service.
|
||||
func (s *Service) UseAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
var dbToken entity.Token
|
||||
resp := db.Where("value = ?", req.Token).Take(&dbToken)
|
||||
if resp.Error != nil {
|
||||
slog.Info("useAdminToken failed", "error", "token not found in db")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
if dbToken.Type != TOKENTYPE_ADMIN {
|
||||
slog.Info("useAdminToken failed", "error", "token is of wrong type", "type_wanted", TOKENTYPE_ADMIN, "type_actual", dbToken.Type)
|
||||
if dbToken.Type != entity.TOKENTYPE_ADMIN {
|
||||
slog.Info("useAdminToken failed", "error", "token is of wrong type", "type_wanted", entity.TOKENTYPE_ADMIN, "type_actual", dbToken.Type)
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
dur := time.Since(dbToken.CreatedAt)
|
||||
if dur > tokenMaxAge {
|
||||
if dur > token.TokenMaxAge {
|
||||
slog.Info("useAdminToken failed", "error", "token in db has expired")
|
||||
return errors.New("invalid token")
|
||||
}
|
||||
@@ -558,11 +356,11 @@ func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
// Incase removing the token after used fails, this is in a transaction so user wont be admin.
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Give user admin
|
||||
if err := tx.Model(&User{}).Where("id = ?", userId).Update("permissions", PERM_ADMIN).Error; err != nil {
|
||||
if err := tx.Model(&entity.User{}).Where("id = ?", userId).Update("permissions", entity.PERM_ADMIN).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// Delete used token
|
||||
if err := tx.Where("value = ?", req.Token).Delete(&Token{}).Error; err != nil {
|
||||
if err := tx.Where("value = ?", req.Token).Delete(&entity.Token{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
@@ -575,13 +373,13 @@ func useAdminToken(req *UseAdminTokenRequest, db *gorm.DB, userId uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func signJWT(user *User) (token string, err error) {
|
||||
func (s *Service) signJWT(user *entity.User) (token string, err error) {
|
||||
// Create new jwt with claim data
|
||||
jwt := jwt.NewWithClaims(jwt.SigningMethodHS256, TokenClaims{
|
||||
user.ID,
|
||||
user.Username,
|
||||
user.Type,
|
||||
jwt.RegisteredClaims{
|
||||
jwt := jwt.NewWithClaims(jwt.SigningMethodHS256, entity.TokenClaims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Type: user.Type,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
// ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
Issuer: "watcharr",
|
||||
@@ -589,28 +387,43 @@ func signJWT(user *User) (token string, err error) {
|
||||
})
|
||||
|
||||
// Sign and get the complete encoded token as a string using the secret
|
||||
return jwt.SignedString([]byte(Config.JWT_SECRET))
|
||||
return jwt.SignedString([]byte(s.cfg.JWT_SECRET))
|
||||
}
|
||||
|
||||
func hashPassword(password string, p *ArgonParams) (encodedHash string, err error) {
|
||||
salt, err := generateRandomBytes(p.saltLength)
|
||||
func (s *Service) hashPassword(password string, p *entity.ArgonParams) (encodedHash string, err error) {
|
||||
salt, err := s.generateRandomBytes(p.SaltLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)
|
||||
hash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
p.Iterations,
|
||||
p.Memory,
|
||||
p.Parallelism,
|
||||
p.KeyLength,
|
||||
)
|
||||
|
||||
// Base64 encode the salt and hashed password.
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
// Format hash in standard way.
|
||||
encodedHash = fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash)
|
||||
encodedHash = fmt.Sprintf(
|
||||
"$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version,
|
||||
p.Memory,
|
||||
p.Iterations,
|
||||
p.Parallelism,
|
||||
b64Salt,
|
||||
b64Hash,
|
||||
)
|
||||
|
||||
return encodedHash, nil
|
||||
}
|
||||
|
||||
func generateRandomBytes(n uint32) ([]byte, error) {
|
||||
func (s *Service) generateRandomBytes(n uint32) ([]byte, error) {
|
||||
b := make([]byte, n)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
@@ -620,16 +433,23 @@ func generateRandomBytes(n uint32) ([]byte, error) {
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func compareHash(password, encodedHash string) (match bool, err error) {
|
||||
func (s *Service) compareHash(password, encodedHash string) (match bool, err error) {
|
||||
// Extract the parameters, salt and derived key from the encoded password
|
||||
// hash.
|
||||
p, salt, hash, err := decodeHash(encodedHash)
|
||||
p, salt, hash, err := s.decodeHash(encodedHash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Derive the key from the other password using the same parameters.
|
||||
otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength)
|
||||
otherHash := argon2.IDKey(
|
||||
[]byte(password),
|
||||
salt,
|
||||
p.Iterations,
|
||||
p.Memory,
|
||||
p.Parallelism,
|
||||
p.KeyLength,
|
||||
)
|
||||
|
||||
// Check that the contents of the hashed passwords are identical. Note
|
||||
// that we are using the subtle.ConstantTimeCompare() function for this
|
||||
@@ -640,7 +460,7 @@ func compareHash(password, encodedHash string) (match bool, err error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err error) {
|
||||
func (s *Service) decodeHash(encodedHash string) (p *entity.ArgonParams, salt, hash []byte, err error) {
|
||||
vals := strings.Split(encodedHash, "$")
|
||||
if len(vals) != 6 {
|
||||
return nil, nil, nil, errors.New("the encoded hash is not in the correct format")
|
||||
@@ -655,8 +475,8 @@ func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err erro
|
||||
return nil, nil, nil, errors.New("incompatible version of argon2")
|
||||
}
|
||||
|
||||
p = &ArgonParams{}
|
||||
_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism)
|
||||
p = &entity.ArgonParams{}
|
||||
_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.Memory, &p.Iterations, &p.Parallelism)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
@@ -665,35 +485,27 @@ func decodeHash(encodedHash string) (p *ArgonParams, salt, hash []byte, err erro
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
p.saltLength = uint32(len(salt))
|
||||
p.SaltLength = uint32(len(salt))
|
||||
|
||||
hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
p.keyLength = uint32(len(hash))
|
||||
p.KeyLength = uint32(len(hash))
|
||||
|
||||
return p, salt, hash, nil
|
||||
}
|
||||
|
||||
func hasPermission(perms int, reqPerm int) bool {
|
||||
// Admins have permission for everything.
|
||||
if perms&PERM_ADMIN == PERM_ADMIN {
|
||||
return true
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
|
||||
func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
|
||||
func (s *Service) UserChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint) error {
|
||||
slog.Debug("userChangePassword request running", "user_id", userId)
|
||||
user := new(User)
|
||||
user := new(entity.User)
|
||||
res := db.Where("id = ?", userId).Select("password").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("userChangePassword failed - failed to retrieve user from database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to retrieve user")
|
||||
}
|
||||
slog.Debug("userChangePassword user found", "user_id", userId)
|
||||
match, err := compareHash(pwds.OldPassword, user.Password)
|
||||
match, err := s.compareHash(pwds.OldPassword, user.Password)
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to compare passwords", "user_id", userId, "error", err)
|
||||
return errors.New("failed to compare passwords")
|
||||
@@ -704,13 +516,13 @@ func userChangePassword(db *gorm.DB, pwds UserPasswordUpdateRequest, userId uint
|
||||
}
|
||||
slog.Debug("userChangePassword hash for current password matches hash in the database", "user_id", userId)
|
||||
slog.Debug("userChangePassword hashing new password", "user_id", userId)
|
||||
hash, err := hashPassword(pwds.NewPassword, GetPassArgonParams())
|
||||
hash, err := s.hashPassword(pwds.NewPassword, entity.GetPassArgonParams())
|
||||
if err != nil {
|
||||
slog.Error("userChangePassword failed - failed to hash new password", "user_id", userId, "error", err)
|
||||
return errors.New("failed to hash new password")
|
||||
}
|
||||
slog.Debug("userChangePassword new password hashed", "user_id", userId)
|
||||
if err := db.Model(&User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
|
||||
if err := db.Model(&entity.User{}).Where("id = ?", userId).Update("password", hash).Error; err != nil {
|
||||
slog.Error("userChangePassword failed - failed to update password in database", "user_id", userId, "error", err)
|
||||
return errors.New("failed to update password")
|
||||
} else {
|
||||
@@ -4,46 +4,42 @@
|
||||
// a header for auth, so this should only be configured if you are
|
||||
// certain your watcharr instance is only available behind your proxy.
|
||||
|
||||
package main
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TrustedHeaderAuthSetting struct {
|
||||
// Required: Should header auth be enabled?
|
||||
// This bool exists so header auth can be toggled
|
||||
// easily without having to remove configuration.
|
||||
// To be actually enabled, HEADER_NAME must also
|
||||
// be set.
|
||||
Enabled bool `json:"enabled"`
|
||||
// Required: What is the name of the trusted header
|
||||
// that will contain the logged in users username?
|
||||
HeaderName string `json:"headerName"`
|
||||
// Should the frontend attempt auto login if
|
||||
// trusted header auth is enabled.
|
||||
AutoLogin bool `json:"autoLogin"`
|
||||
// Where can we redirect the user to logout
|
||||
// of the auth service?
|
||||
LogoutUrl string `json:"logoutUrl"`
|
||||
}
|
||||
|
||||
type TrustedHeaderAuthLogoutDetailsResponse struct {
|
||||
LogoutUrl string `json:"logoutUrl,omitempty"`
|
||||
}
|
||||
|
||||
// Is trusted header auth configured on this server?
|
||||
func trustedHeaderAuthIsEnabled() bool {
|
||||
return Config.HEADER_AUTH.Enabled && Config.HEADER_AUTH.HeaderName != ""
|
||||
type TrustedHeaderService struct {
|
||||
cfg *config.ServerConfig
|
||||
authService Service
|
||||
}
|
||||
|
||||
func setTrustedHeaderAuthSetting(has TrustedHeaderAuthSetting) error {
|
||||
func NewTrustedHeaderService(cfg *config.ServerConfig, authService Service) *TrustedHeaderService {
|
||||
return &TrustedHeaderService{
|
||||
cfg,
|
||||
authService,
|
||||
}
|
||||
}
|
||||
|
||||
// Is trusted header auth configured on this server?
|
||||
func (s *TrustedHeaderService) TrustedHeaderAuthIsEnabled() bool {
|
||||
return s.cfg.HEADER_AUTH.Enabled && s.cfg.HEADER_AUTH.HeaderName != ""
|
||||
}
|
||||
|
||||
func (s *TrustedHeaderService) SetTrustedHeaderAuthSetting(has config.TrustedHeaderAuthSetting) error {
|
||||
slog.Debug("setTrustedHeaderAuthSetting: Attempting to update to new provided value", "new_value", has)
|
||||
Config.HEADER_AUTH = has
|
||||
err := writeConfig()
|
||||
s.cfg.HEADER_AUTH = has
|
||||
err := s.cfg.Write()
|
||||
if err != nil {
|
||||
slog.Error("setTrustedHeaderAuthSetting: Failed to write updated config!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
@@ -54,25 +50,25 @@ func setTrustedHeaderAuthSetting(has TrustedHeaderAuthSetting) error {
|
||||
// Gets proxy logout details.
|
||||
// Details are accessible to any user for the logout flow.
|
||||
// If proxy configured should be checked before using this.
|
||||
func getTrustedHeaderAuthLogoutDetails() *TrustedHeaderAuthLogoutDetailsResponse {
|
||||
func (s *TrustedHeaderService) GetTrustedHeaderAuthLogoutDetails() *TrustedHeaderAuthLogoutDetailsResponse {
|
||||
return &TrustedHeaderAuthLogoutDetailsResponse{
|
||||
LogoutUrl: Config.HEADER_AUTH.LogoutUrl,
|
||||
LogoutUrl: s.cfg.HEADER_AUTH.LogoutUrl,
|
||||
}
|
||||
}
|
||||
|
||||
// Login via header sso
|
||||
func loginTrustedHeaderAuth(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
func (s *TrustedHeaderService) LoginTrustedHeaderAuth(user *entity.User, db *gorm.DB) (AuthResponse, error) {
|
||||
slog.Debug("loginTrustedHeaderAuth: A user is logging in", "username_from_header", user.Username)
|
||||
dbUser := new(User)
|
||||
res := db.Where("username = ? AND type = ?", user.Username, PROXY_USER).Take(&dbUser)
|
||||
dbUser := new(entity.User)
|
||||
res := db.Where("username = ? AND type = ?", user.Username, entity.PROXY_USER).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
slog.Info("loginTrustedHeaderAuth: Creating new User from authentication header", "username_from_header", user.Username)
|
||||
// Record not found, so we should create the user (if configured to do so)
|
||||
// dbUser will be empty, so we can just reuse it for this purpose.
|
||||
dbUser.Username = user.Username
|
||||
dbUser.Type = PROXY_USER
|
||||
dbUser.Country = &Config.DEFAULT_COUNTRY
|
||||
dbUser.Type = entity.PROXY_USER
|
||||
dbUser.Country = &s.cfg.DEFAULT_COUNTRY
|
||||
|
||||
res = db.Create(&dbUser)
|
||||
if res.Error != nil {
|
||||
@@ -84,7 +80,7 @@ func loginTrustedHeaderAuth(user *User, db *gorm.DB) (AuthResponse, error) {
|
||||
return AuthResponse{}, errors.New("error locating user in db")
|
||||
}
|
||||
}
|
||||
token, err := signJWT(dbUser)
|
||||
token, err := s.authService.signJWT(dbUser)
|
||||
if err != nil {
|
||||
slog.Error("loginTrustedHeaderAuth: Failed to sign new jwt", "error", err)
|
||||
return AuthResponse{}, errors.New("failed to get auth token")
|
||||
@@ -0,0 +1,103 @@
|
||||
package authmiddleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/permission"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Auth middleware
|
||||
// If db is passed, extra user info from the database will be fetched.
|
||||
func AuthRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
slog.Debug("AuthRequired middleware hit")
|
||||
atoken := c.GetHeader("Authorization")
|
||||
// Make sure auth header isn't empty
|
||||
if atoken == "" {
|
||||
slog.Warn("Returning 401, Authorization header not provided")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
// Parse token
|
||||
token, err := jwt.ParseWithClaims(atoken, &entity.TokenClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(cfg.JWT_SECRET), nil
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("AuthRequired failed to parse token", "error", err)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
// If token is valid, go to next handler
|
||||
if claims, ok := token.Claims.(*entity.TokenClaims); ok && token.Valid {
|
||||
// Check if token issuedAt is from before `timeOfNewLoginRequired`.
|
||||
// Basically just so we can logout old tokens and force relogin...
|
||||
// since new changes require the user login again.
|
||||
timeOfNewLoginRequired, _ := time.Parse(time.RFC822, "18 Aug 23 20:30 UTC")
|
||||
if claims.IssuedAt.Before(timeOfNewLoginRequired) {
|
||||
slog.Info("Token is from before timeOfNewLoginRequired.. returning 401", "token_issued_at", claims.IssuedAt, "time_of_new_login_required", timeOfNewLoginRequired)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("Token is valid", "claims", claims)
|
||||
c.Set("userId", claims.UserID)
|
||||
c.Set("userType", claims.Type)
|
||||
// If db passed, get extra user info and set as variables in req context
|
||||
if db != nil {
|
||||
slog.Debug("AuthRequired: db passed.. getting extra user info")
|
||||
dbUser := new(entity.User)
|
||||
res := db.Where("id = ?", claims.UserID).Take(&dbUser)
|
||||
if res.Error != nil {
|
||||
slog.Error("AuthRequired: Failed to select user from database", "error", res.Error)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
slog.Debug("AuthRequired: fetched extra user info. Setting vars.", "userThirdPartyId", dbUser.ThirdPartyID, "userThirdPartyAuth", "lol this is censored dude")
|
||||
c.Set("userThirdPartyId", dbUser.ThirdPartyID)
|
||||
c.Set("userThirdPartyAuth", dbUser.ThirdPartyAuth)
|
||||
c.Set("username", dbUser.Username)
|
||||
c.Set("userPermissions", dbUser.Permissions)
|
||||
}
|
||||
c.Next()
|
||||
} else {
|
||||
slog.Error("Token is **not** valid")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Admin only middleware (use after AuthRequired with extra info!)
|
||||
func AdminRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if permission.Has(perms, entity.PERM_ADMIN) {
|
||||
slog.Debug("AdminRequired: User has permission to access admin only route", "user_id", userId)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("AdminRequired: User denied permission to access admin only route", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
|
||||
// Specific perm only middleware (use after AuthRequired with extra info!)
|
||||
func PermRequired(perm int) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.GetUint("userId")
|
||||
perms := c.GetInt("userPermissions")
|
||||
if permission.Has(perms, perm) {
|
||||
slog.Debug("PermRequired: User has permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
slog.Info("PermRequired: User denied permission to access perm only route", "user_id", userId, "required_perm", perm)
|
||||
c.AbortWithStatus(401)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package permission
|
||||
|
||||
import "github.com/sbondCo/Watcharr/database/entity"
|
||||
|
||||
// If `perms` has `req(uired)Perm`.
|
||||
func Has(perms int, reqPerm int) bool {
|
||||
// Admins have permission for everything.
|
||||
if perms&entity.PERM_ADMIN == entity.PERM_ADMIN {
|
||||
return true
|
||||
}
|
||||
return (perms & reqPerm) == reqPerm
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/feature/plex"
|
||||
"github.com/sbondCo/Watcharr/feature/setup/setupglob"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"github.com/sbondCo/Watcharr/token"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
service Service
|
||||
trustedHeaderService TrustedHeaderService
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
auth := r.br.Router.Group("/auth")
|
||||
|
||||
// Login
|
||||
auth.POST("/", r.Login)
|
||||
// Jellyfin login
|
||||
auth.POST("/jellyfin", r.LoginJellyfin)
|
||||
// Plex login
|
||||
auth.POST("/plex", r.LoginPlex)
|
||||
// Proxy Login
|
||||
auth.POST("/proxy", r.LoginProxy)
|
||||
// Register
|
||||
auth.POST("/register", r.Register)
|
||||
// Get available auth providers
|
||||
auth.GET("/available", r.GetAvailableAuthProviders)
|
||||
|
||||
// IMPORTANT: Routes below here must be authenticated.
|
||||
auth.Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
{
|
||||
// Request details for logout process for proxy users.
|
||||
// Any proxy user can request this for logout.
|
||||
auth.GET("/proxy_logout_details", r.GetProxyLogoutDetails)
|
||||
// Request admin token
|
||||
auth.GET("/admin_token", r.GetAdminToken)
|
||||
// Use admin token
|
||||
auth.POST("/admin_token", r.UseAdminToken)
|
||||
// Change password
|
||||
auth.POST("/change_password", r.UpdateUserPassword)
|
||||
}
|
||||
}
|
||||
|
||||
// Login
|
||||
func (r *Router) Login(c *gin.Context) {
|
||||
var user entity.User
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := r.service.Login(&user, r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
}
|
||||
|
||||
// Jellyfin login
|
||||
func (r *Router) LoginJellyfin(c *gin.Context) {
|
||||
var user entity.User
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := r.service.LoginJellyfin(&user, r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
}
|
||||
|
||||
// Plex login
|
||||
func (r *Router) LoginPlex(c *gin.Context) {
|
||||
var plexRequest plex.PlexLoginRequest
|
||||
if c.ShouldBindJSON(&plexRequest) == nil {
|
||||
response, err := r.service.LoginPlex(&plexRequest, r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
}
|
||||
|
||||
// Proxy Login
|
||||
func (r *Router) LoginProxy(c *gin.Context) {
|
||||
var user entity.User
|
||||
if !r.trustedHeaderService.TrustedHeaderAuthIsEnabled() {
|
||||
slog.Error("ProxyLogin: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
user.Username = c.GetHeader(r.br.Cfg.HEADER_AUTH.HeaderName)
|
||||
if user.Username == "" {
|
||||
slog.Error("ProxyLogin: Authentication header is missing.")
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: "authentication header missing"})
|
||||
return
|
||||
}
|
||||
response, err := r.trustedHeaderService.LoginTrustedHeaderAuth(&user, r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Register
|
||||
func (r *Router) Register(c *gin.Context) {
|
||||
var user UserRegisterRequest
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := r.service.Register(&user, entity.PERM_NONE, r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
}
|
||||
|
||||
// Get available auth providers
|
||||
func (r *Router) GetAvailableAuthProviders(c *gin.Context) {
|
||||
resp := &AvailableAuthProvidersResponse{
|
||||
AvailableAuthProviders: []string{},
|
||||
SignupEnabled: r.br.Cfg.SIGNUP_ENABLED,
|
||||
IsInSetup: setupglob.ServerInSetup,
|
||||
UseEmby: r.br.Cfg.USE_EMBY,
|
||||
}
|
||||
if r.br.Cfg.JELLYFIN_HOST != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "jellyfin")
|
||||
}
|
||||
if r.br.Cfg.PLEX_HOST != "" && r.br.Cfg.PLEX_MACHINE_ID != "" {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "plex")
|
||||
}
|
||||
if r.trustedHeaderService.TrustedHeaderAuthIsEnabled() {
|
||||
resp.AvailableAuthProviders = append(resp.AvailableAuthProviders, "header")
|
||||
resp.HeaderAuthAutoLogin = r.br.Cfg.HEADER_AUTH.AutoLogin
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Request details for logout process for proxy users.
|
||||
// Any proxy user can request this for logout.
|
||||
func (r *Router) GetProxyLogoutDetails(c *gin.Context) {
|
||||
if !r.trustedHeaderService.TrustedHeaderAuthIsEnabled() {
|
||||
slog.Error("GetProxy: SSO has not been configured.")
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: "proxy authentication is disabled"})
|
||||
return
|
||||
}
|
||||
userType := c.MustGet("userType").(entity.UserType)
|
||||
if userType != entity.PROXY_USER {
|
||||
slog.Error("GetProxy: Non proxy user attempted to fetch proxy logout details.")
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: "you are not a proxy user"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, r.trustedHeaderService.GetTrustedHeaderAuthLogoutDetails())
|
||||
}
|
||||
|
||||
// Request admin token
|
||||
func (r *Router) GetAdminToken(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
token, err := token.CreateOneUseToken(r.br.DB, entity.TOKENTYPE_ADMIN, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
slog.Info("Admin token generated. Type this token into the web ui to gain admin access on your account.", "token", token, "generated_for", userId)
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// Use admin token
|
||||
func (r *Router) UseAdminToken(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var atr UseAdminTokenRequest
|
||||
if c.ShouldBindJSON(&atr) == nil {
|
||||
err := r.service.UseAdminToken(&atr, r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
}
|
||||
|
||||
// Change password
|
||||
func (r *Router) UpdateUserPassword(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var pwds UserPasswordUpdateRequest
|
||||
err := c.ShouldBindJSON(&pwds)
|
||||
if err == nil {
|
||||
err := r.service.UserChangePassword(r.br.DB, pwds, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
This is a bit confusing, but `content` only refers to tmdb data at this time. This is mostly because of how this was named when the code was first written, the name is fine once understood: content is for tv/movie/actor and any other modules will be named what they are for specifically (ie: game)
|
||||
@@ -0,0 +1,613 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
gocache "github.com/robfig/go-cache"
|
||||
"github.com/sbondCo/Watcharr/cache"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// inmemory content cache
|
||||
var ContentStore = gocache.New(time.Hour*24, time.Minute)
|
||||
|
||||
// Download file over http (used for downloading poster images)
|
||||
// url - The remote file url.
|
||||
// outf - Where should we store the downloaded file.
|
||||
// force - Should we overwrite an existing file? If false, existing files will be skipped.
|
||||
func download(url string, outf string, force bool) (err error) {
|
||||
slog.Debug("download: Attempting to download file", "url", url, "outf", outf, "force", force)
|
||||
// If not forced, skip call if file already exists to save unnecessary requests.
|
||||
if !force {
|
||||
if _, err := os.Stat(outf); !errors.Is(err, os.ErrNotExist) {
|
||||
slog.Debug("download: Skipping file, it already exists locally.", "outf", outf, "error", err)
|
||||
return nil
|
||||
} else {
|
||||
slog.Debug("download: Continuing to download file, it does not already exist.", "outf", outf, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to make request.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Error("download: Request failed. Non OK response.", "outf", outf, "status", resp.Status, "error", err)
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("download: Failed to create out file, trying to recover by ensuring directories exist.", "outf", outf)
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create dir(s) in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create out file again in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("download: recovered by creating dir(s).", "outf", outf)
|
||||
} else {
|
||||
slog.Error("download: Failed to create out file. No known recovery path possible.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to write file to our file.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("download: Successfully downloaded file", "outf", outf)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
t *tmdb.TMDB
|
||||
}
|
||||
|
||||
func NewService(t *tmdb.TMDB) *Service {
|
||||
return &Service{
|
||||
t,
|
||||
}
|
||||
}
|
||||
|
||||
// onlyUpdate - If we should only update existing row if exists, or false to create/update if not exist.
|
||||
func (s *Service) saveContent(db *gorm.DB, c *entity.Content, onlyUpdate bool) error {
|
||||
slog.Info("Saving content to db", "id", c.TmdbID, "title", c.Title)
|
||||
if c.TmdbID == 0 || c.Title == "" || c.Type == "" {
|
||||
slog.Error("saveContent: content missing id, title or type!", "id", c.TmdbID, "title", c.Title, "type", c.Type)
|
||||
return errors.New("content missing id or title")
|
||||
}
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&entity.Content{}).Where("type = ? AND tmdb_id = ?", c.Type, c.TmdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveContent: Error updating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached content in database")
|
||||
}
|
||||
} else {
|
||||
// On conflict, update existing row with details incase any were updated/missing.
|
||||
res = db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "tmdb_id"}, {Name: "type"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"title",
|
||||
"poster_path",
|
||||
"overview",
|
||||
"release_date",
|
||||
"popularity",
|
||||
"vote_average",
|
||||
"vote_count",
|
||||
"imdb_id",
|
||||
"status",
|
||||
"budget",
|
||||
"revenue",
|
||||
"runtime",
|
||||
"number_of_episodes",
|
||||
"number_of_seasons",
|
||||
}),
|
||||
}).Create(&c)
|
||||
if res.Error != nil {
|
||||
// Error if anything but unique contraint error
|
||||
if res.Error != gorm.ErrDuplicatedKey {
|
||||
slog.Error("saveContent: Error creating content in database", "error", res.Error.Error())
|
||||
return errors.New("failed to cache content in database")
|
||||
}
|
||||
}
|
||||
}
|
||||
// If row created, download the image
|
||||
if res.RowsAffected > 0 {
|
||||
slog.Debug("saveContent: Downloading poster.")
|
||||
err := download(
|
||||
"https://image.tmdb.org/t/p/w500"+c.PosterPath,
|
||||
path.Join(config.DataPath, "img", c.PosterPath),
|
||||
false,
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("saveContent: Failed to download content image!", "error", err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) cacheContentTv(db *gorm.DB, content tmdb.TMDBShowDetails, onlyUpdate bool) (entity.Content, error) {
|
||||
slog.Debug("cacheContentTv", "content", content)
|
||||
var (
|
||||
releaseDate time.Time
|
||||
runtime uint32
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
releaseDate, err := time.Parse(dateFormat, content.FirstAirDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse tv release date", "error", err)
|
||||
}
|
||||
if len(content.EpisodeRunTime) > 0 {
|
||||
runtime = uint32(content.EpisodeRunTime[0])
|
||||
}
|
||||
|
||||
c := entity.Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Name,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: entity.SHOW,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
Status: content.Status,
|
||||
Runtime: runtime,
|
||||
NumberOfEpisodes: content.NumberOfEpisodes,
|
||||
NumberOfSeasons: content.NumberOfSeasons,
|
||||
}
|
||||
|
||||
err = s.saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentTv: Failed to save content!", "error", err)
|
||||
return entity.Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *Service) cacheContentMovie(db *gorm.DB, content tmdb.TMDBMovieDetails, onlyUpdate bool) (entity.Content, error) {
|
||||
var (
|
||||
releaseDate time.Time
|
||||
)
|
||||
var dateFormat = "2006-01-02"
|
||||
// Get details from movie/show response and fill out needed vars
|
||||
releaseDate, err := time.Parse(dateFormat, content.ReleaseDate)
|
||||
if err != nil {
|
||||
slog.Error("Failed to parse movie release date", "error", err)
|
||||
}
|
||||
|
||||
c := entity.Content{
|
||||
TmdbID: content.ID,
|
||||
Title: content.Title,
|
||||
Overview: content.Overview,
|
||||
PosterPath: content.PosterPath,
|
||||
Type: entity.MOVIE,
|
||||
ReleaseDate: &releaseDate,
|
||||
Popularity: content.Popularity,
|
||||
VoteAverage: content.VoteAverage,
|
||||
VoteCount: content.VoteCount,
|
||||
ImdbID: content.ImdbID,
|
||||
Status: content.Status,
|
||||
Budget: content.Budget,
|
||||
Revenue: content.Revenue,
|
||||
Runtime: content.Runtime,
|
||||
}
|
||||
|
||||
err = s.saveContent(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheContentMovie: Failed to save content!", "error", err)
|
||||
return entity.Content{}, errors.New("failed to save content")
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Get content from our db cache, or cache it if it doesn't exist.
|
||||
func (s *Service) GetOrCacheContent(db *gorm.DB, contentType entity.ContentType, tmdbId int) (entity.Content, error) {
|
||||
var content entity.Content
|
||||
// Look in db for content.
|
||||
db.Where("type = ? AND tmdb_id = ?", contentType, tmdbId).Find(&content)
|
||||
// Create content if not found from our db.
|
||||
if content == (entity.Content{}) {
|
||||
slog.Debug("Content not in db, fetching...", "type", contentType, "tmdbId", tmdbId)
|
||||
|
||||
resp, err := s.t.APIRequest("/"+string(contentType)+"/"+strconv.Itoa(tmdbId), map[string]string{})
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: content tmdb api request failed", "error", err)
|
||||
return entity.Content{}, errors.New("failed to find requested media")
|
||||
}
|
||||
|
||||
if contentType == "movie" {
|
||||
c := new(tmdb.TMDBMovieDetails)
|
||||
err := json.Unmarshal([]byte(resp), &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal movie details", "error", err)
|
||||
return entity.Content{}, errors.New("failed to process movie details response")
|
||||
}
|
||||
content, err = s.cacheContentMovie(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: failed to cache movie content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return entity.Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
} else {
|
||||
c := new(tmdb.TMDBShowDetails)
|
||||
err := json.Unmarshal(resp, &c)
|
||||
if err != nil {
|
||||
slog.Error("Failed to unmarshal tv details", "error", err)
|
||||
return entity.Content{}, errors.New("failed to process tv details response")
|
||||
}
|
||||
content, err = s.cacheContentTv(db, *c, false)
|
||||
if err != nil {
|
||||
slog.Error("GetOrCacheContent: failed to cache tv content", "type", contentType, "content_id", tmdbId, "err", err)
|
||||
return entity.Content{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Getting only region needed from api is not a feature yet
|
||||
// https://trello.com/c/75tR4cpF/106-add-watch-provider-region-filtering
|
||||
// When it is, this can be removed for that instead.
|
||||
func (s *Service) transformProviders(c *interface{}, country string) {
|
||||
slog.Debug("transformProviders called", "country", country)
|
||||
if cmap, ok := (*c).(map[string]interface{}); ok {
|
||||
if rmap, ok := cmap["results"].(map[string]interface{}); ok {
|
||||
if val, ok := rmap[country]; ok {
|
||||
slog.Debug("transformProviders: Found country.. overwriting whole object", "new_obj", val)
|
||||
if rvmap, ok := val.(map[string]interface{}); ok {
|
||||
rvmap["country"] = country
|
||||
}
|
||||
*c = val
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find country..", "country", country)
|
||||
}
|
||||
} else {
|
||||
slog.Warn("transformProviders: Couldn't find results property..")
|
||||
}
|
||||
} else {
|
||||
slog.Error("transformProviders: Assertion failed")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) SearchContent(query string, pageNum int) (tmdb.TMDBSearchMultiResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchMultiResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchContent", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchContent: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/search/multi", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete multi search request!", "error", err.Error())
|
||||
return tmdb.TMDBSearchMultiResponse{}, errors.New("failed to complete multi search request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchMovies(query string, pageNum int) (tmdb.TMDBSearchMoviesResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchMoviesResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchMovies", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/search/movie", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie search request!", "error", err.Error())
|
||||
return tmdb.TMDBSearchMoviesResponse{}, errors.New("failed to complete movie search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "movie"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchTv(query string, pageNum int) (tmdb.TMDBSearchShowsResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchShowsResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
cacheKey := cache.CreateCacheKey("SearchTv", query, pageNum)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SearchTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/search/tv", map[string]string{"query": query, "page": strconv.Itoa(pageNum)}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv search request!", "error", err.Error())
|
||||
return tmdb.TMDBSearchShowsResponse{}, errors.New("failed to complete tv search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "tv"
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) SearchPeople(query string, pageNum int) (tmdb.TMDBSearchPeopleResponse, error) {
|
||||
resp := new(tmdb.TMDBSearchPeopleResponse)
|
||||
if pageNum == 0 {
|
||||
pageNum = 1
|
||||
}
|
||||
err := s.t.Request("/search/person", map[string]string{
|
||||
"query": query,
|
||||
"page": strconv.Itoa(pageNum),
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete people search request!", "error", err.Error())
|
||||
return tmdb.TMDBSearchPeopleResponse{}, errors.New("failed to complete people search request")
|
||||
}
|
||||
for i := range resp.Results {
|
||||
resp.Results[i].MediaType = "person"
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Search for content by an external id (imdb, etc).
|
||||
// Defaults to imdb if no source if provided (probably most common).
|
||||
func (s *Service) SearchByExternalId(id string, source string) (tmdb.TMDBSearchMultiResponse, error) {
|
||||
resp := new(tmdb.TMDBFindByExternalIdResponse)
|
||||
if source == "" {
|
||||
source = "imdb"
|
||||
}
|
||||
err := s.t.Request("/find/"+id, map[string]string{"external_source": source + "_id"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete find/external_id request!", "error", err.Error())
|
||||
return tmdb.TMDBSearchMultiResponse{}, errors.New("failed to complete find/external_id request")
|
||||
}
|
||||
comb := []tmdb.TMDBSearchMultiResults{}
|
||||
comb = append(comb, resp.MovieResults...)
|
||||
comb = append(comb, resp.TvResults...)
|
||||
comb = append(comb, resp.PersonResults...)
|
||||
comb = append(comb, resp.TvSeasonResults...)
|
||||
comb = append(comb, resp.TvEpisodeResults...)
|
||||
return tmdb.TMDBSearchMultiResponse{TMDBSearchResponse: tmdb.TMDBSearchResponse[tmdb.TMDBSearchMultiResults]{
|
||||
Results: comb,
|
||||
TMDBPageFields: tmdb.TMDBPageFields{
|
||||
TotalResults: len(comb),
|
||||
// Just providing these so we don't break frontend pagination logic.
|
||||
TotalPages: 1,
|
||||
Page: 1,
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (s *Service) MovieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (tmdb.TMDBMovieDetails, error) {
|
||||
resp := new(tmdb.TMDBMovieDetails)
|
||||
cacheKey := cache.CreateCacheKey("MovieDetails", id, country, rParams)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("MovieDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/movie/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie details request!", "error", err.Error())
|
||||
return tmdb.TMDBMovieDetails{}, errors.New("failed to complete movie details request")
|
||||
}
|
||||
s.transformProviders(&resp.WatchProviders, country)
|
||||
go s.cacheContentMovie(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) MovieCredits(id string) (tmdb.TMDBContentCredits, error) {
|
||||
resp := new(tmdb.TMDBContentCredits)
|
||||
err := s.t.Request("/movie/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete movie cast request!", "error", err.Error())
|
||||
return tmdb.TMDBContentCredits{}, errors.New("failed to complete movie cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) TvDetails(
|
||||
db *gorm.DB,
|
||||
id string,
|
||||
country string,
|
||||
rParams map[string]string,
|
||||
) (tmdb.TMDBShowDetails, error) {
|
||||
cacheKey := cache.CreateCacheKey("TvDetails", id, country, rParams)
|
||||
resp := new(tmdb.TMDBShowDetails)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("TvDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/tv/"+id, rParams, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv details request!", "error", err.Error())
|
||||
return tmdb.TMDBShowDetails{}, errors.New("failed to complete tv details request")
|
||||
}
|
||||
s.transformProviders(&resp.WatchProviders, country)
|
||||
go s.cacheContentTv(db, *resp, true)
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) TvCredits(id string) (tmdb.TMDBContentCredits, error) {
|
||||
resp := new(tmdb.TMDBContentCredits)
|
||||
err := s.t.Request("/tv/"+id+"/credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete tv cast request!", "error", err.Error())
|
||||
return tmdb.TMDBContentCredits{}, errors.New("failed to complete tv cast request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// This method is manually cached, so it can be easily used in other places (on the server) with cache benefits
|
||||
func (s *Service) SeasonDetails(tvId string, seasonNumber string) (tmdb.TMDBSeasonDetails, error) {
|
||||
cacheKey := cache.CreateCacheKey("SeasonDetails", tvId, seasonNumber)
|
||||
resp := new(tmdb.TMDBSeasonDetails)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("SeasonDetails: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/tv/"+tvId+"/season/"+seasonNumber, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("SeasonDetails: Failed to complete season details request!", "error", err.Error())
|
||||
return tmdb.TMDBSeasonDetails{}, errors.New("failed to complete season details request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) PersonDetails(id string) (tmdb.TMDBPersonDetails, error) {
|
||||
resp := new(tmdb.TMDBPersonDetails)
|
||||
err := s.t.Request("/person/"+id, map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return tmdb.TMDBPersonDetails{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) PersonCredits(id string) (tmdb.TMDBPersonCombinedCredits, error) {
|
||||
resp := new(tmdb.TMDBPersonCombinedCredits)
|
||||
err := s.t.Request("/person/"+id+"/combined_credits", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete person details request!", "error", err.Error())
|
||||
return tmdb.TMDBPersonCombinedCredits{}, errors.New("failed to complete person details request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) DiscoverMovies() (tmdb.TMDBDiscoverMovies, error) {
|
||||
cacheKey := cache.CreateCacheKey("DiscoverMovies")
|
||||
resp := new(tmdb.TMDBDiscoverMovies)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("DiscoverMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/discover/movie", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover movies request!", "error", err.Error())
|
||||
return tmdb.TMDBDiscoverMovies{}, errors.New("failed to complete discover movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) DiscoverTv() (tmdb.TMDBDiscoverShows, error) {
|
||||
cacheKey := cache.CreateCacheKey("DiscoverTv")
|
||||
resp := new(tmdb.TMDBDiscoverShows)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("DiscoverTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/discover/tv", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete discover tv request!", "error", err.Error())
|
||||
return tmdb.TMDBDiscoverShows{}, errors.New("failed to complete discover tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) AllTrending() (tmdb.TMDBTrendingAll, error) {
|
||||
cacheKey := cache.CreateCacheKey("AllTrending")
|
||||
resp := new(tmdb.TMDBTrendingAll)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("AllTrending: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/trending/all/day", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete all trending request!", "error", err.Error())
|
||||
return tmdb.TMDBTrendingAll{}, errors.New("failed to complete all trending request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpcomingMovies() (tmdb.TMDBUpcomingMovies, error) {
|
||||
cacheKey := cache.CreateCacheKey("UpcomingMovies")
|
||||
resp := new(tmdb.TMDBUpcomingMovies)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("upcomingMovies: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
err := s.t.Request("/movie/upcoming", map[string]string{"page": "1"}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming movies request!", "error", err.Error())
|
||||
return tmdb.TMDBUpcomingMovies{}, errors.New("failed to complete upcoming movies request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
// Theres no upcoming endpoint for tv ;( - using discover with future dates
|
||||
func (s *Service) UpcomingTv() (tmdb.TMDBUpcomingShows, error) {
|
||||
cacheKey := cache.CreateCacheKey("UpcomingTv")
|
||||
resp := new(tmdb.TMDBUpcomingShows)
|
||||
if cache.GetCache(ContentStore, cacheKey, &resp) {
|
||||
slog.Debug("UpcomingTv: Returning cache.")
|
||||
return *resp, nil
|
||||
}
|
||||
dFmt := "2006-01-02"
|
||||
mind := time.Now().Format(dFmt)
|
||||
maxd := time.Now().AddDate(0, 0, 15).Format(dFmt)
|
||||
err := s.t.Request("/discover/tv", map[string]string{
|
||||
"page": "1",
|
||||
"first_air_date.gte": mind,
|
||||
"first_air_date.lte": maxd,
|
||||
"sort_by": "popularity.desc",
|
||||
"with_type": "2|3",
|
||||
}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete upcoming tv request!", "error", err.Error())
|
||||
return tmdb.TMDBUpcomingShows{}, errors.New("failed to complete upcoming tv request")
|
||||
}
|
||||
ContentStore.Set(cacheKey, resp, time.Hour*24)
|
||||
return *resp, nil
|
||||
}
|
||||
|
||||
func (s *Service) Regions() (tmdb.TMDBRegions, error) {
|
||||
resp := new(tmdb.TMDBRegions)
|
||||
err := s.t.Request("/watch/providers/regions", map[string]string{}, &resp)
|
||||
if err != nil {
|
||||
slog.Error("Failed to complete regions request!", "error", err.Error())
|
||||
return tmdb.TMDBRegions{}, errors.New("failed to complete regions request")
|
||||
}
|
||||
return *resp, nil
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// All the functions that help us turn a TMDB response struct
|
||||
// into one that will also include Watched data.
|
||||
// This process is very verbose. As far as I am aware, golangs
|
||||
// generics are not mature (powerful) enough to support us doing
|
||||
// this all with one function.
|
||||
//
|
||||
// TODO When possible look at turning all these funcs into one that
|
||||
// is reuable for any tmdb search response type.
|
||||
//
|
||||
// Each function will basically perform these simple steps:
|
||||
// 1. Repackage tmdb response so we can add Watched data to
|
||||
// 2. Get all watched data for the tmdb results
|
||||
// 4. Add any watched data to our new *WithWatched struct
|
||||
|
||||
package content
|
||||
|
||||
// OLD CODE:::
|
||||
|
||||
// func searchContentAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBSearchMultiResponse,
|
||||
// ) TMDBSearchMultiResponseWithWatched {
|
||||
// withWatchedResp := TMDBSearchMultiResponseWithWatched{}
|
||||
// withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMultiResultsWithWatched{
|
||||
// TMDBSearchMultiResults: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// ContentType(v.MediaType),
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func searchMoviesAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBSearchMoviesResponse,
|
||||
// ) TMDBSearchMoviesResponseWithWatched {
|
||||
// withWatchedResp := TMDBSearchMoviesResponseWithWatched{}
|
||||
// withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchMovieResultWithWatched{
|
||||
// TMDBSearchMovieResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// ContentType(v.MediaType),
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func searchTvAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBSearchShowsResponse,
|
||||
// ) TMDBSearchShowsResponseWithWatched {
|
||||
// withWatchedResp := TMDBSearchShowsResponseWithWatched{}
|
||||
// withWatchedResp.TMDBSearchResponse.TMDBPageFields = content.TMDBPageFields
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBSearchShowsResultWithWatched{
|
||||
// TMDBSearchShowsResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// ContentType(v.MediaType),
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func movieDetailsAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBMovieDetails,
|
||||
// ) TMDBMovieDetailsWithWatched {
|
||||
// withWatchedResp := TMDBMovieDetailsWithWatched{}
|
||||
// withWatchedResp.TMDBMovieDetailsBase = content.TMDBMovieDetailsBase
|
||||
// // Append watched list entry if exists
|
||||
// if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), MOVIE); err != nil {
|
||||
// if err != gorm.ErrRecordNotFound {
|
||||
// withWatchedResp.FailedToGetWatched = true
|
||||
// }
|
||||
// } else {
|
||||
// withWatchedResp.Watched = &watchedEntry
|
||||
// }
|
||||
// // Add similar content with any watched entries
|
||||
// similarContentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Similar.Results {
|
||||
// withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBMovieSimilarResultWithWatched{
|
||||
// TMDBMovieSimilarResult: v,
|
||||
// })
|
||||
// similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// MOVIE,
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Similar.Results {
|
||||
// if vv.ID == v.Content.TmdbID && string(MOVIE) == string(v.Content.Type) {
|
||||
// withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func tvDetailsAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBShowDetails,
|
||||
// ) TMDBShowDetailsWithWatched {
|
||||
// withWatchedResp := TMDBShowDetailsWithWatched{}
|
||||
// withWatchedResp.TMDBShowDetailsBase = content.TMDBShowDetailsBase
|
||||
// // Append watched list entry if exists
|
||||
// if watchedEntry, err := getWatchedItemByTmdbId(db, userId, uint(content.ID), SHOW); err != nil {
|
||||
// if err != gorm.ErrRecordNotFound {
|
||||
// withWatchedResp.FailedToGetWatched = true
|
||||
// }
|
||||
// } else {
|
||||
// withWatchedResp.Watched = &watchedEntry
|
||||
// }
|
||||
// // Add similar content with any watched entries
|
||||
// similarContentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Similar.Results {
|
||||
// withWatchedResp.Similar.Results = append(withWatchedResp.Similar.Results, TMDBShowSimilarResultWithWatched{
|
||||
// TMDBShowSimilarResult: v,
|
||||
// })
|
||||
// similarContentIdAndTypePairs = append(similarContentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// SHOW,
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, similarContentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Similar.Results {
|
||||
// if vv.ID == v.Content.TmdbID && string(SHOW) == string(v.Content.Type) {
|
||||
// withWatchedResp.Similar.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func allTrendingAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBTrendingAll,
|
||||
// ) TMDBTrendingAllWithWatched {
|
||||
// withWatchedResp := TMDBTrendingAllWithWatched{}
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBTrendingAllResultWithWatched{
|
||||
// TMDBTrendingAllResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// ContentType(v.MediaType),
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && vv.MediaType == string(v.Content.Type) {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func discoverTvAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBDiscoverShows,
|
||||
// ) TMDBDiscoverShowsWithWatched {
|
||||
// withWatchedResp := TMDBDiscoverShowsWithWatched{}
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverShowsResultWithWatched{
|
||||
// TMDBDiscoverShowsResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// SHOW,
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func upcomingTvAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBUpcomingShows,
|
||||
// ) TMDBUpcomingShowsWithWatched {
|
||||
// withWatchedResp := TMDBUpcomingShowsWithWatched{}
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingShowsResultWithWatched{
|
||||
// TMDBUpcomingShowsResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// SHOW,
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && SHOW == v.Content.Type {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func discoverMoviesAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBDiscoverMovies,
|
||||
// ) TMDBDiscoverMoviesWithWatched {
|
||||
// withWatchedResp := TMDBDiscoverMoviesWithWatched{}
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBDiscoverMoviesResultWithWatched{
|
||||
// TMDBDiscoverMoviesResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// MOVIE,
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
|
||||
// func upcomingMoviesAddWatched(
|
||||
// db *gorm.DB,
|
||||
// userId uint,
|
||||
// content TMDBUpcomingMovies,
|
||||
// ) TMDBUpcomingMoviesWithWatched {
|
||||
// withWatchedResp := TMDBUpcomingMoviesWithWatched{}
|
||||
// contentIdAndTypePairs := [][]any{}
|
||||
// for _, v := range content.Results {
|
||||
// withWatchedResp.Results = append(withWatchedResp.Results, TMDBUpcomingMoviesResultWithWatched{
|
||||
// TMDBUpcomingMoviesResult: v,
|
||||
// })
|
||||
// contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
// v.ID,
|
||||
// MOVIE,
|
||||
// })
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.Results {
|
||||
// if vv.ID == v.Content.TmdbID && MOVIE == v.Content.Type {
|
||||
// withWatchedResp.Results[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by tmdbIds failed!")
|
||||
// }
|
||||
// return withWatchedResp
|
||||
// }
|
||||
@@ -0,0 +1,380 @@
|
||||
package content
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/addedtocontent"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WatchedProvider interface {
|
||||
UpdateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error
|
||||
GetWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]entity.Watched, error)
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
cs *Service
|
||||
wp WatchedProvider
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter, cs *Service, wp WatchedProvider) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
cs: cs,
|
||||
wp: wp,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
content := r.br.Router.Group("/content").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
exp := time.Hour * 24
|
||||
|
||||
// TODO verify the routes that use cache here actually need it
|
||||
// (because watched data will be added to most)
|
||||
|
||||
// Search for content
|
||||
content.GET("/search/multi", router.PaginatedRequest(true), r.GetSearchMulti)
|
||||
// Search for movies
|
||||
content.GET("/search/movie", router.PaginatedRequest(true), r.GetSearchMovie)
|
||||
// Search for shows
|
||||
content.GET("/search/tv", router.PaginatedRequest(true), r.GetSearchTv)
|
||||
// Search for people
|
||||
content.GET("/search/person", router.PaginatedRequest(true), cache.CachePage(r.br.MemStore, exp, r.GetSearchPerson))
|
||||
// Search for content with external id
|
||||
content.GET("/search/ext/:id/:source", cache.CachePage(r.br.MemStore, exp, r.GetSearchByExternalId))
|
||||
// Get movie details (for movie page)
|
||||
content.GET("/movie/:id", router.WhereaboutsRequired(r.br.Cfg), cache.CachePage(r.br.MemStore, exp, r.GetMovieDetails))
|
||||
// Get movie cast
|
||||
content.GET("/movie/:id/credits", cache.CachePage(r.br.MemStore, exp, r.GetMovieCredits))
|
||||
// Get tv details (for tv page)
|
||||
content.GET("/tv/:id", router.WhereaboutsRequired(r.br.Cfg), r.GetTvDetails)
|
||||
// Get tv cast
|
||||
content.GET("/tv/:id/credits", cache.CachePage(r.br.MemStore, exp, r.GetTvCredits))
|
||||
// Get season details
|
||||
// Supports `watchedId` query parameter for saving the requested season as `LastViewedSeason`.
|
||||
content.GET("/tv/:id/season/:num", r.GetSeasonDetails)
|
||||
// Get person details
|
||||
content.GET("/person/:id", cache.CachePage(r.br.MemStore, exp, r.GetPerson))
|
||||
// Get person credits
|
||||
content.GET("/person/:id/credits", cache.CachePage(r.br.MemStore, exp, r.GetPersonCredits))
|
||||
// Discover movies
|
||||
content.GET("/discover/movies", r.GetDiscoverMovies)
|
||||
// Discover shows
|
||||
content.GET("/discover/tv", r.GetDiscoverTv)
|
||||
// Get all trending (movies, tv, people)
|
||||
content.GET("/trending", r.GetTrending)
|
||||
// Upcoming Movies
|
||||
content.GET("/upcoming/movies", r.GetUpcomingMovies)
|
||||
// Upcoming Tv
|
||||
content.GET("/upcoming/tv", r.GetUpcomingTv)
|
||||
// Available regions for watch providers
|
||||
content.GET("/regions", r.GetRegions)
|
||||
}
|
||||
|
||||
func (r *Router) GetSearchMulti(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pp := c.MustGet("paginationParams").(util.PaginationParams)
|
||||
content, err := r.cs.SearchContent(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := searchContentAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
|
||||
addedtocontent.AddWAC(content.Results, r.wp, r.br.DB, userId)
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetSearchMovie(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pp := c.MustGet("paginationParams").(util.PaginationParams)
|
||||
content, err := r.cs.SearchMovies(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := searchMoviesAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetSearchTv(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pp := c.MustGet("paginationParams").(util.PaginationParams)
|
||||
content, err := r.cs.SearchTv(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := searchTvAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetSearchPerson(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
pp := c.MustGet("paginationParams").(util.PaginationParams)
|
||||
content, err := r.cs.SearchPeople(query, pp.Page)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetSearchByExternalId(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := r.cs.SearchByExternalId(c.Param("id"), c.Param("source"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetMovieDetails(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := r.cs.MovieDetails(
|
||||
r.br.DB,
|
||||
c.Param("id"),
|
||||
c.MustGet("userCountry").(string),
|
||||
map[string]string{
|
||||
"append_to_response": "videos,watch/providers,similar",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := movieDetailsAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetMovieCredits(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.MovieCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetTvDetails(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
// 1. Get details
|
||||
content, err := r.cs.TvDetails(
|
||||
r.br.DB,
|
||||
c.Param("id"),
|
||||
c.MustGet("userCountry").(string),
|
||||
map[string]string{
|
||||
"append_to_response": "videos,watch/providers,similar,external_ids,keywords",
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := tvDetailsAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetTvCredits(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.TvCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
// Get season details
|
||||
// Supports `watchedId` query parameter for saving the requested season as `LastViewedSeason`.
|
||||
func (r *Router) GetSeasonDetails(c *gin.Context) {
|
||||
if c.Param("id") == "" || c.Param("num") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.SeasonDetails(c.Param("id"), c.Param("num"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// If a `watchedId` is passed, we should update it with this season
|
||||
// number, so the LastViewedSeason field is up to date (this seemed
|
||||
// better than making a new request for just saving this).
|
||||
// We will attach a `watcharr-lastviewedseason-saved` header if
|
||||
// this part succeeds so the client can decide on showing an error.
|
||||
if watchedIdQ := c.Query("watchedId"); watchedIdQ != "" {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
watchedId, err := strconv.ParseUint(watchedIdQ, 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("get season details route: Processing watchedId param failed", "error", err.Error(), "id", watchedIdQ)
|
||||
} else {
|
||||
if seasonNum, err := strconv.ParseInt(c.Param("num"), 10, 64); err == nil {
|
||||
if err = r.wp.UpdateWatchedLastViewedSeason(r.br.DB, userId, uint(watchedId), int(seasonNum)); err == nil {
|
||||
c.Header("watcharr-lastviewedseason-saved", "1")
|
||||
}
|
||||
} else {
|
||||
slog.Error("get season details route: Parsing season number as int failed", "error", err.Error(), "season_num", c.Param("num"))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Debug("get season details route: No watchedId parameter found.. not doing anything.")
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetPerson(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.PersonDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetPersonCredits(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
content, err := r.cs.PersonCredits(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetDiscoverMovies(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
content, err := r.cs.DiscoverMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := discoverMoviesAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetDiscoverTv(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
content, err := r.cs.DiscoverTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := discoverTvAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetTrending(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
content, err := r.cs.AllTrending()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := allTrendingAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetUpcomingMovies(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
content, err := r.cs.UpcomingMovies()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := upcomingMoviesAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetUpcomingTv(c *gin.Context) {
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
content, err := r.cs.UpcomingTv()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// withWatchedResp := upcomingTvAddWatched(r.br.DB, userId, content)
|
||||
// c.JSON(http.StatusOK, withWatchedResp)
|
||||
// HACK TEST
|
||||
c.JSON(http.StatusOK, content)
|
||||
}
|
||||
|
||||
func (r *Router) GetRegions(c *gin.Context) {
|
||||
re, err := r.cs.Regions()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, re)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/permission"
|
||||
)
|
||||
|
||||
type ServerFeatures struct {
|
||||
Sonarr bool `json:"sonarr"`
|
||||
Radarr bool `json:"radarr"`
|
||||
Games bool `json:"games"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.ServerConfig
|
||||
}
|
||||
|
||||
func NewService(cfg *config.ServerConfig) *Service {
|
||||
return &Service{
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Get enabled server functionality from Config.
|
||||
// Mainly so the frontend can store this once and know
|
||||
// which btns should be shown, etc.
|
||||
func (s *Service) GetEnabledFeatures(userPerms int) ServerFeatures {
|
||||
var f ServerFeatures
|
||||
if s.cfg.TWITCH.ClientID != nil && s.cfg.TWITCH.ClientSecret != nil {
|
||||
f.Games = true
|
||||
}
|
||||
if permission.Has(userPerms, entity.PERM_REQUEST_CONTENT) {
|
||||
if len(s.cfg.SONARR) > 0 {
|
||||
f.Sonarr = true
|
||||
}
|
||||
if len(s.cfg.RADARR) > 0 {
|
||||
f.Radarr = true
|
||||
}
|
||||
}
|
||||
return f
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package feature
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
*router.BaseRouter
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter, service *Service) *Router {
|
||||
return &Router{
|
||||
BaseRouter: br,
|
||||
service: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
feature := r.Router.Group("/features").Use(authmiddleware.AuthRequired(r.DB, r.Cfg))
|
||||
|
||||
// Get enabled features (aka functionality)
|
||||
feature.GET("", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, r.service.GetEnabledFeatures(c.GetInt("userPermissions")))
|
||||
})
|
||||
}
|
||||
@@ -1,39 +1,30 @@
|
||||
package main
|
||||
package follow
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Database struct, only internal.
|
||||
type Follow struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
UserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null;check:user_id != followed_user_id" json:"-"`
|
||||
User User `json:"-"`
|
||||
FollowedUserID uint `gorm:"primaryKey:usr_id_to_followed_id;not null" json:"-"`
|
||||
FollowedUser User `json:"-"`
|
||||
}
|
||||
|
||||
// For end users to see.
|
||||
type FollowPublic struct {
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
FollowedUser entity.PublicUser `json:"followedUser"`
|
||||
}
|
||||
|
||||
type FollowThoughts struct {
|
||||
FollowedUser PublicUser `json:"followedUser"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating"`
|
||||
FollowedUser entity.PublicUser `json:"followedUser"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating"`
|
||||
}
|
||||
|
||||
func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPublic, error) {
|
||||
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Model(&Follow{}).Create(&f)
|
||||
f := entity.Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Model(&entity.Follow{}).Create(&f)
|
||||
if res.Error != nil {
|
||||
slog.Error("followUser: Error on inserting follow.", "error", res.Error)
|
||||
err := "failed to insert follow"
|
||||
@@ -43,7 +34,7 @@ func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPub
|
||||
return FollowPublic{}, errors.New(err)
|
||||
}
|
||||
// Now get the row with preloaded followed user
|
||||
var nf Follow
|
||||
var nf entity.Follow
|
||||
res = db.Where("user_id = ? AND followed_user_id = ?", currentUserId, toFollowUserId).Preload("FollowedUser", "private = ?", 0).Take(&nf)
|
||||
if res.Error != nil {
|
||||
slog.Error("followUser: Couldn't fetch newly followed user.", "error", res.Error)
|
||||
@@ -53,7 +44,7 @@ func followUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (FollowPub
|
||||
}
|
||||
|
||||
func unfollowUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (bool, error) {
|
||||
f := Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
f := entity.Follow{UserID: currentUserId, FollowedUserID: toFollowUserId}
|
||||
res := db.Delete(&f)
|
||||
if res.Error != nil {
|
||||
slog.Error("unfollowUser: Error deleting follow.", "error", res.Error)
|
||||
@@ -68,7 +59,7 @@ func unfollowUser(db *gorm.DB, currentUserId uint, toFollowUserId uint) (bool, e
|
||||
|
||||
// Get current users follows
|
||||
func getFollows(db *gorm.DB, userId uint) ([]FollowPublic, error) {
|
||||
var follows []Follow
|
||||
var follows []entity.Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ?", 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding follows.", "error", res.Error)
|
||||
@@ -89,7 +80,7 @@ func getFollows(db *gorm.DB, userId uint) ([]FollowPublic, error) {
|
||||
|
||||
// Get followed profile thoughts, rating, etc on specific content.
|
||||
func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId string) ([]FollowThoughts, error) {
|
||||
var follows []Follow
|
||||
var follows []entity.Follow
|
||||
res := db.Where("user_id = ?", userId).Preload("FollowedUser", "private = ? AND private_thoughts = ?", 0, 0).Find(&follows)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding follows.", "error", res.Error)
|
||||
@@ -107,7 +98,7 @@ func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId stri
|
||||
var contentOrGameId int
|
||||
if mediaType == "game" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Game
|
||||
var content entity.Game
|
||||
res = db.Where("igdb_id = ?", mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
@@ -116,7 +107,7 @@ func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId stri
|
||||
contentOrGameId = content.ID
|
||||
} else if mediaType == "movie" || mediaType == "tv" {
|
||||
// Get our content id from type and tmdbId
|
||||
var content Content
|
||||
var content entity.Content
|
||||
res = db.Where("type = ? AND tmdb_id = ?", mediaType, mediaId).Select("id").Find(&content)
|
||||
if res.Error != nil {
|
||||
slog.Error("getFollows: Error finding content from db.", "error", res.Error)
|
||||
@@ -128,7 +119,7 @@ func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId stri
|
||||
return []FollowThoughts{}, errors.New("unrecognized media type")
|
||||
}
|
||||
// Get list of followeds watcheds for this content
|
||||
var fw []Watched
|
||||
var fw []entity.Watched
|
||||
if mediaType == "game" {
|
||||
res = db.Where("game_id = ? AND user_id IN ?", contentOrGameId, followIds).Find(&fw)
|
||||
} else {
|
||||
@@ -141,7 +132,7 @@ func getFollowsThoughts(db *gorm.DB, userId uint, mediaType string, mediaId stri
|
||||
// Create followThoughts array by combining follows and fw(atcheds)
|
||||
ft := []FollowThoughts{}
|
||||
for _, v := range fw {
|
||||
var fu PublicUser
|
||||
var fu entity.PublicUser
|
||||
for _, f := range follows {
|
||||
if f.FollowedUser.ID == v.UserID {
|
||||
fu = f.FollowedUser.GetSafe()
|
||||
@@ -0,0 +1,94 @@
|
||||
package follow
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
f := r.br.Router.Group("/follow").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg))
|
||||
|
||||
// Get users follows
|
||||
f.GET("", r.GetFollows)
|
||||
// Follow a user
|
||||
f.POST("/:toFollowId", r.AddFollowUser)
|
||||
// Unfollow a user
|
||||
f.DELETE("/:toUnfollowId", r.DeleteFollow)
|
||||
// Get follows thoughts on content
|
||||
// TODO Rename `tmdbId` to `mediaId` to match what it is actually used as (since it works for games).
|
||||
f.GET("/thoughts/:type/:tmdbId", r.GetFollowsThoughts)
|
||||
}
|
||||
|
||||
// Get users follows // TODO extend to support optionally passing user id as route param, default to current user
|
||||
func (r *Router) GetFollows(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getFollows(r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Follow a user
|
||||
func (r *Router) AddFollowUser(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
toFollowId, err := strconv.ParseUint(c.Param("toFollowId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert toFollowId param to uint", "toFollowId", toFollowId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := followUser(r.br.DB, userId, uint(toFollowId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Unfollow a user
|
||||
func (r *Router) DeleteFollow(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
toUnfollowId, err := strconv.ParseUint(c.Param("toUnfollowId"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("failed to convert toUnfollowId param to uint", "toUnfollowId", toUnfollowId)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := unfollowUser(r.br.DB, userId, uint(toUnfollowId))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Get follows thoughts on content
|
||||
func (r *Router) GetFollowsThoughts(c *gin.Context) {
|
||||
t := c.Param("type")
|
||||
if t != "movie" && t != "tv" && t != "game" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "only movie, tv or game types are supported"})
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getFollowsThoughts(r.br.DB, userId, t, c.Param("tmdbId"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -1,56 +1,34 @@
|
||||
package main
|
||||
package game
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"github.com/sbondCo/Watcharr/feature/image"
|
||||
"github.com/sbondCo/Watcharr/media/igdb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// For storing cached games, so we can serve the basic local data for watched list to work
|
||||
type Game struct {
|
||||
ID int `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
IgdbID int `json:"igdbId" gorm:"uniqueIndex;not null"`
|
||||
Name string `json:"name"`
|
||||
CoverID string `json:"coverId"`
|
||||
Summary string `json:"summary"`
|
||||
Storyline string `json:"storyline"`
|
||||
// First release date
|
||||
ReleaseDate *time.Time `json:"releaseDate,omitempty"`
|
||||
Rating float64 `json:"rating"`
|
||||
RatingCount int `json:"ratingCount"`
|
||||
Status int `json:"status"`
|
||||
Category int `json:"category"`
|
||||
// Arrays turned to strings that may be useful
|
||||
GameModes string `json:"gameModes"`
|
||||
Genres string `json:"genres"`
|
||||
Platforms string `json:"platforms"`
|
||||
// Id to poster image row (cached game cover)
|
||||
PosterID *uint `json:"-"`
|
||||
Poster *Image `json:"poster,omitempty"`
|
||||
}
|
||||
|
||||
type PlayedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
IgdbID int `json:"igdbId" binding:"required"`
|
||||
}
|
||||
|
||||
// Cache(save) game to our table
|
||||
func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
|
||||
func saveGame(db *gorm.DB, c *entity.Game, onlyUpdate bool) error {
|
||||
slog.Info("Saving game to db", "id", c.IgdbID, "name", c.Name)
|
||||
if c.IgdbID == 0 || c.Name == "" {
|
||||
slog.Error("saveGame: content missing id or name!", "id", c.IgdbID, "name", c.Name)
|
||||
return errors.New("game missing id or title")
|
||||
}
|
||||
if c.CoverID != "" {
|
||||
p, err := downloadAndInsertImage(db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
p, err := image.DownloadAndInsertImage(db, "https://images.igdb.com/igdb/image/upload/t_cover_big/"+c.CoverID+".png", "games")
|
||||
if err != nil {
|
||||
slog.Error("saveGame: Failed to cache game cover.", "error", err)
|
||||
} else {
|
||||
@@ -61,7 +39,7 @@ func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
|
||||
var res *gorm.DB
|
||||
if onlyUpdate {
|
||||
// We only want to update an existing row, if it exists.
|
||||
res = db.Model(&Game{}).Where("igdb_id = ?", c.IgdbID).Updates(c)
|
||||
res = db.Model(&entity.Game{}).Where("igdb_id = ?", c.IgdbID).Updates(c)
|
||||
if res.Error != nil {
|
||||
slog.Error("saveGame: Error updating game in database", "error", res.Error.Error())
|
||||
return errors.New("failed to update cached game in database")
|
||||
@@ -94,7 +72,7 @@ func saveGame(db *gorm.DB, c *Game, onlyUpdate bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (Game, error) {
|
||||
func cacheGame(db *gorm.DB, g igdb.GameDetailsBasicResponse, onlyUpdate bool) (entity.Game, error) {
|
||||
slog.Debug("cacheGame", "game_details", g)
|
||||
var (
|
||||
gameModes string
|
||||
@@ -116,7 +94,7 @@ func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (G
|
||||
platforms += v.Name + "|"
|
||||
}
|
||||
}
|
||||
c := Game{
|
||||
c := entity.Game{
|
||||
IgdbID: g.ID,
|
||||
Name: g.Name,
|
||||
CoverID: g.Cover.ImageID,
|
||||
@@ -134,79 +112,79 @@ func cacheGame(db *gorm.DB, g game.GameDetailsBasicResponse, onlyUpdate bool) (G
|
||||
err := saveGame(db, &c, onlyUpdate)
|
||||
if err != nil {
|
||||
slog.Error("cacheGame: Failed to save game!", "error", err)
|
||||
return Game{}, errors.New("failed to save game")
|
||||
return entity.Game{}, errors.New("failed to save game")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// For adding/updating played games, we will reuse methods defined in watched.go where easily possible.
|
||||
|
||||
func addPlayed(db *gorm.DB, igdb *game.IGDB, userId uint, ar PlayedAddRequest, at ActivityType) (Watched, error) {
|
||||
func addPlayed(db *gorm.DB, igdb *igdb.IGDB, userId uint, ar PlayedAddRequest, at entity.ActivityType) (entity.Watched, error) {
|
||||
slog.Debug("Adding played item", "userId", userId, "igdbId", ar.IgdbID)
|
||||
|
||||
var game Game
|
||||
var game entity.Game
|
||||
db.Where("igdb_id = ?", ar.IgdbID).Find(&game)
|
||||
|
||||
// Create game if not found from our db
|
||||
if game == (Game{}) {
|
||||
if game == (entity.Game{}) {
|
||||
slog.Debug("Game not in db, fetching...")
|
||||
|
||||
resp, err := igdb.GameDetailsBasic(strconv.Itoa(ar.IgdbID))
|
||||
if err != nil {
|
||||
slog.Error("addPlayed content tmdb api request failed", "error", err)
|
||||
return Watched{}, errors.New("failed to find requested games")
|
||||
return entity.Watched{}, errors.New("failed to find requested games")
|
||||
}
|
||||
|
||||
game, err = cacheGame(db, resp, false)
|
||||
if err != nil {
|
||||
slog.Error("addPlayed failed to cache game", "igdb_id", ar.IgdbID, "err", err)
|
||||
return Watched{}, errors.New("failed to cache content")
|
||||
return entity.Watched{}, errors.New("failed to cache content")
|
||||
}
|
||||
}
|
||||
// Error if content has no id
|
||||
if game.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find game by id")
|
||||
return entity.Watched{}, errors.New("failed to find game by id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
ar.Status = FINISHED
|
||||
ar.Status = entity.FINISHED
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, GameID: &game.ID}
|
||||
watched := entity.Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, GameID: &game.ID}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND game_id = ?", userId, watched.GameID).Take(&watched)
|
||||
res = db.Model(&entity.Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND game_id = ?", userId, watched.GameID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
return entity.Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return Watched{}, errors.New("content already on watched list")
|
||||
return entity.Watched{}, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addPlayed: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND game_id = ?", userId, watched.GameID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
res = db.Model(&entity.Watched{}).Unscoped().Where("user_id = ? AND game_id = ?", userId, watched.GameID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
if res.Error != nil {
|
||||
slog.Error("addPlayed: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
return entity.Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
return entity.Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var activity Activity
|
||||
var act entity.Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
act, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
act, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Activity = append(watched.Activity, act)
|
||||
watched.Game = &game
|
||||
return watched, nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"github.com/sbondCo/Watcharr/media/igdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GameDetailsResponseWithPlayed struct {
|
||||
igdb.GameDetailsResponseBase
|
||||
SimilarGame []GameSimilarWithWatched `json:"similar_games"`
|
||||
// WatchedAddedToContent
|
||||
}
|
||||
|
||||
type GameSimilarWithWatched struct {
|
||||
igdb.GameSimilar
|
||||
// WatchedAddedToContent
|
||||
}
|
||||
|
||||
// HACK commented out for now.. make it work nicely with content first, then port it over here nicely.
|
||||
|
||||
func gameDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content igdb.GameDetailsResponse,
|
||||
) GameDetailsResponseWithPlayed {
|
||||
withWatchedResp := GameDetailsResponseWithPlayed{}
|
||||
// withWatchedResp.GameDetailsResponseBase = content.GameDetailsResponseBase
|
||||
// // Append watched list entry if exists
|
||||
// if watchedEntry, err := getWatchedItemByIgdbId(db, userId, uint(content.ID)); err != nil {
|
||||
// if err != gorm.ErrRecordNotFound {
|
||||
// withWatchedResp.FailedToGetWatched = true
|
||||
// }
|
||||
// } else {
|
||||
// withWatchedResp.Watched = &watchedEntry
|
||||
// }
|
||||
// // Add similar content with any watched entries
|
||||
// similarContentIds := []int{}
|
||||
// for _, v := range content.SimilarGame {
|
||||
// withWatchedResp.SimilarGame = append(
|
||||
// withWatchedResp.SimilarGame,
|
||||
// GameSimilarWithWatched{
|
||||
// GameSimilar: v,
|
||||
// },
|
||||
// )
|
||||
// similarContentIds = append(similarContentIds, v.ID)
|
||||
// }
|
||||
// if ws, err := getWatchedItemsByIgdbIds(db, userId, similarContentIds); err == nil {
|
||||
// for _, v := range ws {
|
||||
// for i, vv := range withWatchedResp.SimilarGame {
|
||||
// if vv.ID == v.Game.IgdbID {
|
||||
// withWatchedResp.SimilarGame[i].WatchedAddedToContent.Watched = &v
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// // TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
// slog.Error("Getting watched items by igdbIds failed!")
|
||||
// }
|
||||
return withWatchedResp
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package game
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/media/igdb"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
gamer := r.br.Router.Group("/game").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
exp := time.Hour * 24
|
||||
|
||||
// TODO This config init can be moved to NewRouter, then `gdb` can be accessible in Router for all service funcs.
|
||||
r.br.Cfg.TWITCH.OnTokenRefreshed(func() {
|
||||
// Save new token to config when we refresh it.
|
||||
slog.Debug("GameRoutes: token refreshed.. saving to config.")
|
||||
if err := r.br.Cfg.Write(); err != nil {
|
||||
slog.Error("GameRoutes: failed to save refreshed token to config.", "error", err)
|
||||
}
|
||||
})
|
||||
err := r.br.Cfg.TWITCH.Init()
|
||||
// Save cfg if init succeeded, this will save our access token
|
||||
if err != nil {
|
||||
slog.Error("GameRoutes: Twitch init failed!", "error", err)
|
||||
}
|
||||
|
||||
// Search for games
|
||||
gamer.GET("/search", cache.CachePage(r.br.MemStore, exp, r.GetSearch))
|
||||
// Search for game by id (for search page, same minimal details as /search returned)
|
||||
gamer.GET("/search/:id", cache.CachePage(r.br.MemStore, exp, r.GetSearchById))
|
||||
// Game details for game page
|
||||
gamer.GET("/:id", cache.CachePage(r.br.MemStore, exp, r.GetGameDetails))
|
||||
// Add game to played(watched) list
|
||||
gamer.POST("/played", r.AddPlayed)
|
||||
|
||||
// IMPORTANT: Routes below only for admins!
|
||||
gamer.Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg), authmiddleware.AdminRequired())
|
||||
{
|
||||
gamer.POST("/config", r.UpdateConfig)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) GetSearch(c *gin.Context) {
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
decodedQuery, err := url.QueryUnescape(query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "query parameter invalid"})
|
||||
return
|
||||
}
|
||||
games, err := r.br.Cfg.TWITCH.Search(decodedQuery)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}
|
||||
|
||||
func (r *Router) GetSearchById(c *gin.Context) {
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
games, err := r.br.Cfg.TWITCH.SearchById(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, games)
|
||||
}
|
||||
|
||||
func (r *Router) GetGameDetails(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
if c.Param("id") == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "an id was not provided"})
|
||||
return
|
||||
}
|
||||
content, err := r.br.Cfg.TWITCH.GameDetails(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
withWatchedResp := gameDetailsAddWatched(r.br.DB, userId, content)
|
||||
c.JSON(http.StatusOK, withWatchedResp)
|
||||
}
|
||||
|
||||
func (r *Router) AddPlayed(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar PlayedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := addPlayed(r.br.DB, &r.br.Cfg.TWITCH, userId, ar, entity.ADDED_WATCHED)
|
||||
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) UpdateConfig(c *gin.Context) {
|
||||
var ar igdb.IGDB
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
err := r.br.Cfg.SaveTwitchConfig(ar)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
// gdb = &b.cfg.TWITCH
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package image
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -17,41 +17,33 @@ import (
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/buckket/go-blurhash"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// For user uploaded images
|
||||
type Image struct {
|
||||
ID uint `gorm:"primarykey" json:"-"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Hash string `gorm:"uniqueIndex;not null" json:"-"`
|
||||
BlurHash string `json:"blurHash"`
|
||||
// Path constructable from hash alone, but I can't decide
|
||||
// if I should have this or not so I figure it's easier
|
||||
// to remove it later than to add it later....... -_-
|
||||
Path string `gorm:"not null" json:"path"`
|
||||
}
|
||||
// TODO now that this file is in the image package it no longer needs to have "image(s)"
|
||||
// in the name of all the functions..
|
||||
|
||||
// Insert an image into database
|
||||
func insertImage(db *gorm.DB, hash string, path string, f io.Reader) (Image, error) {
|
||||
bh, _ := getBlurHash(f)
|
||||
img := Image{
|
||||
func InsertImage(db *gorm.DB, hash string, path string, f io.Reader) (entity.Image, error) {
|
||||
bh, _ := GetBlurHash(f)
|
||||
img := entity.Image{
|
||||
Hash: hash,
|
||||
Path: path,
|
||||
BlurHash: bh,
|
||||
}
|
||||
r := db.Where(Image{Hash: hash}).FirstOrCreate(&img)
|
||||
r := db.Where(entity.Image{Hash: hash}).FirstOrCreate(&img)
|
||||
if r.Error != nil {
|
||||
slog.Error("insertImage firstOrCreate failed!", "error", r.Error)
|
||||
return Image{}, errors.New("failed to select or create image")
|
||||
return entity.Image{}, errors.New("failed to select or create image")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func getBlurHash(img io.Reader) (string, error) {
|
||||
func GetBlurHash(img io.Reader) (string, error) {
|
||||
i, _, err := image.Decode(img)
|
||||
if err != nil {
|
||||
// Handle errors
|
||||
@@ -68,9 +60,9 @@ func getBlurHash(img io.Reader) (string, error) {
|
||||
return bh, nil
|
||||
}
|
||||
|
||||
func cleanupImages(db *gorm.DB) {
|
||||
func CleanupImages(db *gorm.DB) {
|
||||
slog.Info("cleanupImages running")
|
||||
var unusedImgs []Image
|
||||
var unusedImgs []entity.Image
|
||||
// Select images that are not referenced by at least one other row.
|
||||
// Currently only used for user avatars, add new tables when used.
|
||||
db.Raw(`SELECT *
|
||||
@@ -86,11 +78,11 @@ WHERE NOT EXISTS (
|
||||
slog.Debug("cleanupImages: removing an image", "id", v.ID, "path", v.Path)
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
// Try to delete image from db
|
||||
if err := tx.Where("id = ?", v.ID).Delete(&Image{}).Error; err != nil {
|
||||
if err := tx.Where("id = ?", v.ID).Delete(&entity.Image{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// hope its ok to do this sorta thing here :skull:
|
||||
if err := os.Remove(path.Join(DataPath, v.Path)); err != nil {
|
||||
if err := os.Remove(path.Join(config.DataPath, v.Path)); err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
@@ -105,7 +97,7 @@ WHERE NOT EXISTS (
|
||||
}
|
||||
}
|
||||
|
||||
func isValidImageType(f multipart.File) error {
|
||||
func IsValidImageType(f multipart.File) error {
|
||||
// Read first 512 bytes, since that is all `DetectContentType` will evaluate on.
|
||||
// Reading whole file is a waste.
|
||||
buff := make([]byte, 512)
|
||||
@@ -122,19 +114,19 @@ func isValidImageType(f multipart.File) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image, error) {
|
||||
func DownloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (entity.Image, error) {
|
||||
slog.Debug("Attempting to download image", "url", url)
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
return entity.Image{}, fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Read body into byte array, then create new reader
|
||||
@@ -142,7 +134,7 @@ func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image,
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage failed to read response into byte array", "error", err)
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
br := bytes.NewReader(b)
|
||||
|
||||
@@ -156,11 +148,11 @@ func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image,
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
outp := path.Join("img/", imgSubPath, hs[0:1], hs+filepath.Ext(resp.Request.URL.Path))
|
||||
dataOutP := path.Join(DataPath, outp)
|
||||
dataOutP := path.Join(config.DataPath, outp)
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(dataOutP)
|
||||
@@ -168,33 +160,33 @@ func downloadAndInsertImage(db *gorm.DB, url string, imgSubPath string) (Image,
|
||||
if os.IsNotExist(err) {
|
||||
err = os.MkdirAll(path.Dir(dataOutP), 0764)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(dataOutP)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
} else {
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
_, err = io.Copy(out, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
// Seek back for insertImage
|
||||
_, err = br.Seek(0, 0)
|
||||
if err != nil {
|
||||
slog.Error("downloadAndInsertImage seeking back to start of br failed", "error", err)
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
img, err := insertImage(db, hs, outp, br)
|
||||
img, err := InsertImage(db, hs, outp, br)
|
||||
if err != nil {
|
||||
return Image{}, err
|
||||
return entity.Image{}, err
|
||||
}
|
||||
|
||||
return img, nil
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package imprt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -8,6 +8,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"github.com/sbondCo/Watcharr/feature/tag"
|
||||
"github.com/sbondCo/Watcharr/feature/watched"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/episode"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/season"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -27,78 +34,114 @@ var (
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
Name string `json:"name"`
|
||||
Year int `json:"year"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type ContentType `json:"type"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []WatchedSeason `json:"watchedSeasons"`
|
||||
Tags []TagAddRequest `json:"tags"`
|
||||
ImdbID string `json:"imdbId"`
|
||||
Name string `json:"name"`
|
||||
Year int `json:"year"`
|
||||
TmdbID int `json:"tmdbId"`
|
||||
Type entity.ContentType `json:"type"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
RatingCustomDate *time.Time `json:"ratingCustomDate"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
DatesWatched []time.Time `json:"datesWatched"`
|
||||
Activity []entity.Activity `json:"activity"`
|
||||
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes"`
|
||||
WatchedSeason []entity.WatchedSeason `json:"watchedSeasons"`
|
||||
Tags []tag.TagAddRequest `json:"tags"`
|
||||
ImdbID string `json:"imdbId"`
|
||||
}
|
||||
|
||||
type ImportResponse struct {
|
||||
Type ImportResponseType `json:"type"`
|
||||
Results []TMDBSearchMultiResults `json:"results"`
|
||||
Match TMDBSearchMultiResults `json:"match"`
|
||||
Type ImportResponseType `json:"type"`
|
||||
Results []tmdb.TMDBSearchMultiResults `json:"results"`
|
||||
Match tmdb.TMDBSearchMultiResults `json:"match"`
|
||||
// On success this will be filled with the new watched entry
|
||||
WatchedEntry Watched `json:"watchedEntry"`
|
||||
WatchedEntry entity.Watched `json:"watchedEntry"`
|
||||
}
|
||||
|
||||
func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse, error) {
|
||||
type WatchedProvider interface {
|
||||
AddWatched(db *gorm.DB, userId uint, ar watched.WatchedAddRequest, at entity.ActivityType) (entity.Watched, error)
|
||||
GetWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType entity.ContentType) (entity.Watched, error)
|
||||
}
|
||||
|
||||
type WatchedSeasonProvider interface {
|
||||
AddWatchedSeason(db *gorm.DB, userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
|
||||
}
|
||||
|
||||
type WatchedEpisodeProvider interface {
|
||||
AddWatchedEpisodes(db *gorm.DB, userId uint, ar episode.WatchedEpisodeAddRequest) (episode.WatchedEpisodeAddResponse, error)
|
||||
}
|
||||
|
||||
type ContentProvider interface {
|
||||
SearchContent(query string, pageNum int) (tmdb.TMDBSearchMultiResponse, error)
|
||||
SearchByExternalId(id string, source string) (tmdb.TMDBSearchMultiResponse, error)
|
||||
MovieDetails(db *gorm.DB, id string, country string, rParams map[string]string) (tmdb.TMDBMovieDetails, error)
|
||||
TvDetails(db *gorm.DB, id string, country string, rParams map[string]string) (tmdb.TMDBShowDetails, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
wp WatchedProvider
|
||||
wsp WatchedSeasonProvider
|
||||
wep WatchedEpisodeProvider
|
||||
cp ContentProvider
|
||||
}
|
||||
|
||||
func NewService(wp WatchedProvider, wsp WatchedSeasonProvider, wep WatchedEpisodeProvider, cp ContentProvider) *Service {
|
||||
return &Service{
|
||||
wp,
|
||||
wsp,
|
||||
wep,
|
||||
cp,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ImportContent(db *gorm.DB, userId uint, ar ImportRequest) (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 == MOVIE || ar.Type == SHOW) {
|
||||
if ar.TmdbID != 0 && (ar.Type == entity.MOVIE || ar.Type == entity.SHOW) {
|
||||
tid := strconv.Itoa(ar.TmdbID)
|
||||
if ar.Type == MOVIE {
|
||||
cr, err := movieDetails(db, tid, "", map[string]string{})
|
||||
if ar.Type == entity.MOVIE {
|
||||
cr, err := s.cp.MovieDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("movie details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of movie", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, MOVIE, ar)
|
||||
} else if ar.Type == SHOW {
|
||||
cr, err := tvDetails(db, tid, "", map[string]string{})
|
||||
return s.SuccessfulImport(db, userId, cr.ID, entity.MOVIE, ar)
|
||||
} else if ar.Type == entity.SHOW {
|
||||
cr, err := s.cp.TvDetails(db, tid, "", map[string]string{})
|
||||
if err != nil {
|
||||
return ImportResponse{}, errors.New("tv details request failed")
|
||||
}
|
||||
slog.Debug("import: by tmdbid of tv", "cr", cr)
|
||||
return successfulImport(db, userId, cr.ID, SHOW, ar)
|
||||
return s.SuccessfulImport(db, userId, cr.ID, entity.SHOW, ar)
|
||||
}
|
||||
}
|
||||
// If imdb id passed, attempt to get content with it
|
||||
if ar.ImdbID != "" && (ar.Type == MOVIE || ar.Type == SHOW || ar.Type == SHOW_EPISODE) {
|
||||
if imdbResp, err := searchByExternalId(ar.ImdbID, "imdb"); err == nil {
|
||||
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(MOVIE) || onlyResult.MediaType == string(SHOW) {
|
||||
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 successfulImport(db, userId, onlyResult.ID, ContentType(onlyResult.MediaType), ar)
|
||||
} else if onlyResult.MediaType == string(SHOW_EPISODE) {
|
||||
return s.SuccessfulImport(db, userId, onlyResult.ID, entity.ContentType(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 := getWatchedItemByTmdbId(db, userId, uint(onlyResult.ShowId), "tv")
|
||||
w, e := s.wp.GetWatchedItemByTmdbId(db, 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 ImportResponse{Type: IMPORT_FAILED}, nil
|
||||
}
|
||||
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
ws, err := s.wep.AddWatchedEpisodes(db, userId, episode.WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: onlyResult.SeasonNumber,
|
||||
EpisodeNumber: onlyResult.EpisodeNumber,
|
||||
Status: ar.Status,
|
||||
Rating: int8(ar.Rating),
|
||||
addActivityDate: *ar.RatingCustomDate,
|
||||
AddActivityDate: *ar.RatingCustomDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("import: imdb match: Failed to add watched episode.", "rq", ar, "error", err)
|
||||
@@ -120,12 +163,12 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
}
|
||||
}
|
||||
// tmdbId not passed.. search for the content by name.
|
||||
sr, err := searchContent(ar.Name, 1)
|
||||
sr, err := s.cp.SearchContent(ar.Name, 1)
|
||||
if err != nil {
|
||||
slog.Error("import: content search failed", "error", err)
|
||||
return ImportResponse{}, errors.New("Content search failed")
|
||||
}
|
||||
pMatches := []TMDBSearchMultiResults{}
|
||||
pMatches := []tmdb.TMDBSearchMultiResults{}
|
||||
for _, r := range sr.Results {
|
||||
if r.MediaType != "person" {
|
||||
pMatches = append(pMatches, r)
|
||||
@@ -141,7 +184,7 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
// 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 := []TMDBSearchMultiResults{}
|
||||
perfectMatches := []tmdb.TMDBSearchMultiResults{}
|
||||
for _, r := range pMatches {
|
||||
itemName := r.Name
|
||||
if itemName == "" {
|
||||
@@ -185,18 +228,18 @@ func importContent(db *gorm.DB, userId uint, ar ImportRequest) (ImportResponse,
|
||||
pmLen := len(perfectMatches)
|
||||
if pmLen == 1 && perfectMatches[0].ID != 0 {
|
||||
slog.Debug("import: importing from perfect match")
|
||||
return successfulImport(db, userId, perfectMatches[0].ID, ContentType(perfectMatches[0].MediaType), ar)
|
||||
return s.SuccessfulImport(db, userId, perfectMatches[0].ID, entity.ContentType(perfectMatches[0].MediaType), ar)
|
||||
}
|
||||
slog.Debug("import: returning all potential matches")
|
||||
return ImportResponse{Type: IMPORT_MULTI, Results: pMatches}, nil
|
||||
} else {
|
||||
slog.Debug("import: success.. only found one result")
|
||||
return successfulImport(db, userId, pMatches[0].ID, ContentType(pMatches[0].MediaType), ar)
|
||||
return s.SuccessfulImport(db, userId, pMatches[0].ID, entity.ContentType(pMatches[0].MediaType), ar)
|
||||
}
|
||||
}
|
||||
|
||||
func successfulImport(db *gorm.DB, userId uint, contentId int, contentType ContentType, ar ImportRequest) (ImportResponse, error) {
|
||||
status := FINISHED
|
||||
func (s *Service) SuccessfulImport(db *gorm.DB, userId uint, contentId int, contentType entity.ContentType, ar ImportRequest) (ImportResponse, error) {
|
||||
status := entity.FINISHED
|
||||
if ar.Status != "" {
|
||||
status = ar.Status
|
||||
}
|
||||
@@ -209,14 +252,14 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
}
|
||||
}
|
||||
}
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
w, err := s.wp.AddWatched(db, userId, watched.WatchedAddRequest{
|
||||
Status: status,
|
||||
ContentID: contentId,
|
||||
ContentType: contentType,
|
||||
Rating: ar.Rating,
|
||||
Thoughts: ar.Thoughts,
|
||||
WatchedDate: wDate,
|
||||
}, IMPORTED_WATCHED)
|
||||
}, 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)
|
||||
@@ -227,12 +270,12 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
}
|
||||
// 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 Activity
|
||||
var addedActivity entity.Activity
|
||||
if len(w.Activity) > 0 {
|
||||
activityJson, _ := json.Marshal(map[string]interface{}{"rating": ar.Rating, "linkedActivity": w.Activity[0].ID})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: string(activityJson), CustomDate: ar.RatingCustomDate})
|
||||
} else {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_RATING, Data: strconv.Itoa(int(ar.Rating)), CustomDate: ar.RatingCustomDate})
|
||||
}
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
}
|
||||
@@ -240,7 +283,7 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
if len(ar.DatesWatched) > 0 {
|
||||
for _, v := range ar.DatesWatched {
|
||||
customDate := v
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
addedActivity, err := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED, CustomDate: &customDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
@@ -257,7 +300,7 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
if activityDate == nil || activityDate.IsZero() {
|
||||
activityDate = &ar.Activity[i].CreatedAt
|
||||
}
|
||||
addedActivity, err := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
|
||||
addedActivity, err := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: v.Type, Data: v.Data, CustomDate: activityDate})
|
||||
if err == nil {
|
||||
w.Activity = append(w.Activity, addedActivity)
|
||||
} else {
|
||||
@@ -269,12 +312,12 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
if len(ar.WatchedSeason) > 0 {
|
||||
slog.Debug("successfulImport: Importing watched seasons")
|
||||
for _, v := range ar.WatchedSeason {
|
||||
ws, err := addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
ws, err := s.wsp.AddWatchedSeason(db, userId, season.WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: v.SeasonNumber,
|
||||
Status: v.Status,
|
||||
Rating: v.Rating,
|
||||
addActivityDate: v.CreatedAt,
|
||||
AddActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched season.", "error", err)
|
||||
@@ -287,13 +330,13 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
if len(ar.WatchedEpisodes) > 0 {
|
||||
slog.Debug("successfulImport: Importing watched episodes")
|
||||
for _, v := range ar.WatchedEpisodes {
|
||||
ws, err := addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
ws, err := s.wep.AddWatchedEpisodes(db, userId, episode.WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: v.SeasonNumber,
|
||||
EpisodeNumber: v.EpisodeNumber,
|
||||
Status: v.Status,
|
||||
Rating: v.Rating,
|
||||
addActivityDate: v.CreatedAt,
|
||||
AddActivityDate: v.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to add watched episodes.", "error", err)
|
||||
@@ -308,14 +351,14 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
slog.Debug("successfulImport: Importing tags")
|
||||
for _, v := range ar.Tags {
|
||||
// Check if tag exists
|
||||
var t Tag
|
||||
t, err := getTagByNameAndColor(db, userId, v.Name, v.Color, v.BgColor)
|
||||
var t entity.Tag
|
||||
t, err := tag.GetTagByNameAndColor(db, 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)
|
||||
continue
|
||||
}
|
||||
if t.ID == 0 {
|
||||
tag, err := addTag(db, userId, TagAddRequest{
|
||||
tag, err := tag.AddTag(db, userId, tag.TagAddRequest{
|
||||
Name: v.Name,
|
||||
Color: v.Color,
|
||||
BgColor: v.BgColor,
|
||||
@@ -328,7 +371,7 @@ func successfulImport(db *gorm.DB, userId uint, contentId int, contentType Conte
|
||||
}
|
||||
|
||||
// Associate the watched entry with the tag
|
||||
err = addWatchedToTag(db, userId, t.ID, w.ID)
|
||||
err = watched.AddWatchedToTag(db, userId, t.ID, w.ID)
|
||||
if err != nil {
|
||||
slog.Error("successfulImport: Failed to associate watched entry with tag.", "error", err)
|
||||
continue
|
||||
@@ -1,6 +1,6 @@
|
||||
// Trakt.tv importer.
|
||||
|
||||
package main
|
||||
package imprt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/job"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -93,22 +96,32 @@ type TraktImportResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
type TraktService struct {
|
||||
s *Service
|
||||
}
|
||||
|
||||
func NewTraktService(s *Service) *TraktService {
|
||||
return &TraktService{
|
||||
s,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO we could support trakt list imports when we support a similar feature (tags will function as custom lists when done #199)
|
||||
func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername string) {
|
||||
func (t *TraktService) startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername string) {
|
||||
// Get trakt user. We want to get their profile `slug` for use in
|
||||
// next requests and we can check their profile isn't private while here.
|
||||
var traktUser TraktUser
|
||||
_, err := traktAPIRequest("users/"+traktUsername, map[string]string{}, &traktUser)
|
||||
_, err := t.traktAPIRequest("users/"+traktUsername, map[string]string{}, &traktUser)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users profile", "error", err, "trakt_user", traktUser)
|
||||
addJobError(jobId, userId, "failed to request trakt profile from api")
|
||||
updateJobStatus(jobId, userId, JOB_CANCELLED)
|
||||
job.AddJobError(jobId, userId, "failed to request trakt profile from api")
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_CANCELLED)
|
||||
return
|
||||
}
|
||||
if traktUser.Private {
|
||||
slog.Error("startTraktImport: Users profile is private. Cannot continue with import.")
|
||||
addJobError(jobId, userId, "trakt profile is private")
|
||||
updateJobStatus(jobId, userId, JOB_CANCELLED)
|
||||
job.AddJobError(jobId, userId, "trakt profile is private")
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_CANCELLED)
|
||||
return
|
||||
}
|
||||
userSlug := traktUser.IDs.Slug
|
||||
@@ -117,24 +130,24 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
// Process all history for this user (in chunks of 1000).
|
||||
var history []TraktHistory
|
||||
slog.Debug("startTraktImport: Getting first history page")
|
||||
historyHeaders, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000"}, &history)
|
||||
historyHeaders, err := t.traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000"}, &history)
|
||||
if err != nil {
|
||||
// FATAL if we can't get the users history, we probably shouldn't continue (to ratings/watchlist below).
|
||||
slog.Error("startTraktImport: Failed to get users history", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your history")
|
||||
job.AddJobError(jobId, userId, "failed to get your history")
|
||||
return
|
||||
} else {
|
||||
pageCount := historyHeaders.Get("x-pagination-page-count")
|
||||
slog.Debug("startTraktImport: Got first history page", "page_count", pageCount)
|
||||
if pageCount == "" {
|
||||
slog.Error("startTraktImport: Failed to get history page count!", "page_count", pageCount)
|
||||
addJobError(jobId, userId, "Failed to get history page count")
|
||||
job.AddJobError(jobId, userId, "Failed to get history page count")
|
||||
return
|
||||
}
|
||||
pageCountNum, err := strconv.Atoi(pageCount)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to parse history page count into an int!", "error", err)
|
||||
addJobError(jobId, userId, "Failed to parse history page count: "+pageCount)
|
||||
job.AddJobError(jobId, userId, "Failed to parse history page count: "+pageCount)
|
||||
return
|
||||
}
|
||||
rProc := func(v TraktHistory) {
|
||||
@@ -147,11 +160,11 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
collectingText = v.Movie.Title
|
||||
}
|
||||
if collectingText != "" {
|
||||
updateJobCurrentTask(jobId, userId, "collecting "+collectingText)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "collecting "+collectingText)
|
||||
}
|
||||
err = processTraktHistoryItem(v, toImport)
|
||||
err = t.processTraktHistoryItem(v, toImport)
|
||||
if err != nil {
|
||||
addJobError(jobId, userId, err.Error())
|
||||
job.AddJobError(jobId, userId, err.Error())
|
||||
}
|
||||
}
|
||||
// Process first page of history (next pages processed below)
|
||||
@@ -160,10 +173,10 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
}
|
||||
for i := range pageCountNum {
|
||||
slog.Debug("startTraktImport: Getting history page", "page_num", i)
|
||||
_, err := traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000", "page": strconv.Itoa(i)}, &history)
|
||||
_, err := t.traktAPIRequest("users/"+userSlug+"/history", map[string]string{"limit": "1000", "page": strconv.Itoa(i)}, &history)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get a history page", "page_num", i, "error", err)
|
||||
addJobError(jobId, userId, "Failed to get history page: "+strconv.Itoa(i))
|
||||
job.AddJobError(jobId, userId, "Failed to get history page: "+strconv.Itoa(i))
|
||||
} else {
|
||||
for _, v := range history {
|
||||
rProc(v)
|
||||
@@ -176,33 +189,33 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
// Get watchlist for PLANNED items
|
||||
slog.Info("startTraktImport: Getting whole watchlist")
|
||||
var watchlist TraktWatchlist
|
||||
_, err = traktAPIRequest("users/"+userSlug+"/watchlist", map[string]string{}, &watchlist)
|
||||
_, err = t.traktAPIRequest("users/"+userSlug+"/watchlist", map[string]string{}, &watchlist)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users watchlist! Cannot import planned content.", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your watchlist (planned items cannot be imported)")
|
||||
job.AddJobError(jobId, userId, "failed to get your watchlist (planned items cannot be imported)")
|
||||
} else {
|
||||
slog.Debug("startTraktImport: Successfully got whole watchlist")
|
||||
for _, v := range watchlist {
|
||||
slog.Debug("startTraktImport: Processing watchlist item", "item", v)
|
||||
var (
|
||||
title string
|
||||
contentType ContentType
|
||||
contentType entity.ContentType
|
||||
tmdbId int
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
contentType = SHOW
|
||||
contentType = entity.SHOW
|
||||
if v.Type == "episode" {
|
||||
title = v.Episode.Title
|
||||
}
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
contentType = entity.MOVIE
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "setting status for "+title)
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "setting status for "+title)
|
||||
mapKey := t.makeTraktMapKey(contentType, tmdbId)
|
||||
if mv, ok := toImport[mapKey]; ok {
|
||||
// If item already exists in toImport, set its status to planned.
|
||||
if v.Type == "episode" {
|
||||
@@ -210,25 +223,25 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
weFound := false
|
||||
for i, we := range mv.WatchedEpisodes {
|
||||
if we.SeasonNumber == v.Episode.Season && we.EpisodeNumber == v.Episode.Number {
|
||||
we.Status = PLANNED
|
||||
we.Status = entity.PLANNED
|
||||
mv.WatchedEpisodes[i] = we
|
||||
weFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !weFound {
|
||||
mv.WatchedEpisodes = append(mv.WatchedEpisodes, WatchedEpisode{
|
||||
mv.WatchedEpisodes = append(mv.WatchedEpisodes, entity.WatchedEpisode{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: PLANNED,
|
||||
GormModel: GormModel{
|
||||
Status: entity.PLANNED,
|
||||
GormModel: dbmodel.GormModel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
})
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
} else {
|
||||
mv.Status = PLANNED
|
||||
mv.Status = entity.PLANNED
|
||||
if v.Notes != "" {
|
||||
// episodes dont support notes in watcharr
|
||||
mv.Thoughts = v.Notes
|
||||
@@ -240,14 +253,14 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
ti := ImportRequest{
|
||||
Type: contentType,
|
||||
TmdbID: tmdbId,
|
||||
Status: PLANNED,
|
||||
Status: entity.PLANNED,
|
||||
}
|
||||
if v.Type == "episode" {
|
||||
ti.WatchedEpisodes = []WatchedEpisode{{
|
||||
ti.WatchedEpisodes = []entity.WatchedEpisode{{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: PLANNED,
|
||||
GormModel: GormModel{
|
||||
Status: entity.PLANNED,
|
||||
GormModel: dbmodel.GormModel{
|
||||
CreatedAt: v.ListedAt,
|
||||
},
|
||||
}}
|
||||
@@ -262,17 +275,17 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
// Process ratings
|
||||
slog.Info("startTraktImport: Getting all ratings")
|
||||
var ratings TraktRatings
|
||||
_, err = traktAPIRequest("users/"+userSlug+"/ratings", map[string]string{}, &ratings)
|
||||
_, err = t.traktAPIRequest("users/"+userSlug+"/ratings", map[string]string{}, &ratings)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to get users ratings!", "error", err)
|
||||
addJobError(jobId, userId, "failed to get your ratings (content ratings cannot be imported)")
|
||||
job.AddJobError(jobId, userId, "failed to get your ratings (content ratings cannot be imported)")
|
||||
} else {
|
||||
slog.Debug("startTraktImport: Successfully got all ratings")
|
||||
for _, v := range ratings {
|
||||
slog.Debug("startTraktImport: Processing rating item", "item", v)
|
||||
var (
|
||||
title string
|
||||
contentType ContentType
|
||||
contentType entity.ContentType
|
||||
tmdbId int
|
||||
traktSlug string
|
||||
)
|
||||
@@ -280,7 +293,7 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
title = v.Show.Title
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
traktSlug = v.Show.Ids.Slug
|
||||
contentType = SHOW
|
||||
contentType = entity.SHOW
|
||||
if v.Type == "episode" {
|
||||
title = v.Episode.Title
|
||||
traktSlug = v.Episode.Ids.Slug
|
||||
@@ -288,11 +301,11 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
} else if v.Type == "movie" {
|
||||
title = v.Movie.Title
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
contentType = entity.MOVIE
|
||||
traktSlug = v.Movie.Ids.Slug
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
job.UpdateJobCurrentTask(jobId, userId, fmt.Sprintf("setting rating of %d for %s", v.Rating, title))
|
||||
mapKey := t.makeTraktMapKey(contentType, tmdbId)
|
||||
if mv, ok := toImport[mapKey]; ok {
|
||||
if v.Type == "episode" {
|
||||
// For episode entries, we have to find the WatchedEpisode to set its rating.
|
||||
@@ -307,7 +320,7 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
}
|
||||
toImport[mapKey] = mv
|
||||
if !epFound {
|
||||
addJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
|
||||
job.AddJobError(jobId, userId, fmt.Sprintf("episode rating of %d for %s not imported. The episode does not exist in your history or watchlist.", v.Rating, title))
|
||||
}
|
||||
} else {
|
||||
mv.Rating = float64(v.Rating)
|
||||
@@ -315,43 +328,43 @@ func startTraktImport(db *gorm.DB, jobId string, userId uint, traktUsername stri
|
||||
}
|
||||
} else {
|
||||
// Item should be in toImport by now (from history or watchlist) if it has a rating, otherwise we won't import it
|
||||
addJobError(jobId, userId, fmt.Sprintf("cannot import rating of %d for %s. The main content does not exist in your history or watchlist. type: %s traktSlug: %s", v.Rating, title, v.Type, traktSlug))
|
||||
job.AddJobError(jobId, userId, fmt.Sprintf("cannot import rating of %d for %s. The main content does not exist in your history or watchlist. type: %s traktSlug: %s", v.Rating, title, v.Type, traktSlug))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Loop over `toImport` and finally import everything.
|
||||
for _, v := range toImport {
|
||||
_, err := importContent(db, userId, v)
|
||||
_, err := t.s.ImportContent(db, userId, v)
|
||||
if err != nil {
|
||||
slog.Error("startTraktImport: Failed to do import on content!", "error", err, "import_obj", v)
|
||||
addJobError(jobId, userId, fmt.Sprintf("Failed to import %s as %s. tmdbId: %d", v.Type, v.Status, v.TmdbID))
|
||||
job.AddJobError(jobId, userId, fmt.Sprintf("Failed to import %s as %s. tmdbId: %d", v.Type, v.Status, v.TmdbID))
|
||||
}
|
||||
}
|
||||
// We are donezo
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_DONE)
|
||||
}
|
||||
|
||||
func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest) error {
|
||||
func (t *TraktService) processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest) error {
|
||||
var (
|
||||
title string
|
||||
traktId int
|
||||
tmdbId int
|
||||
contentType ContentType
|
||||
watchedEpisode WatchedEpisode
|
||||
contentType entity.ContentType
|
||||
watchedEpisode entity.WatchedEpisode
|
||||
)
|
||||
if v.Type == "show" || v.Type == "episode" {
|
||||
title = v.Show.Title
|
||||
traktId = v.Show.Ids.Trakt
|
||||
tmdbId = v.Show.Ids.Tmdb
|
||||
contentType = SHOW
|
||||
contentType = entity.SHOW
|
||||
if v.Type == "episode" {
|
||||
traktId = v.Episode.Ids.Trakt
|
||||
watchedEpisode = WatchedEpisode{
|
||||
watchedEpisode = entity.WatchedEpisode{
|
||||
SeasonNumber: v.Episode.Season,
|
||||
EpisodeNumber: v.Episode.Number,
|
||||
Status: FINISHED,
|
||||
Status: entity.FINISHED,
|
||||
// Rating: ,
|
||||
GormModel: GormModel{
|
||||
GormModel: dbmodel.GormModel{
|
||||
CreatedAt: v.WatchedAt,
|
||||
},
|
||||
}
|
||||
@@ -363,14 +376,14 @@ func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest)
|
||||
title = v.Movie.Title
|
||||
traktId = v.Movie.Ids.Trakt
|
||||
tmdbId = v.Movie.Ids.Tmdb
|
||||
contentType = MOVIE
|
||||
contentType = entity.MOVIE
|
||||
slog.Debug("processTraktHistoryItem: Processing a movie.", "contentTitle", title, "contentTmdbId", tmdbId)
|
||||
}
|
||||
if tmdbId == 0 {
|
||||
slog.Debug("processTraktHistoryItem: Item had no tmdbId. Cannot process.")
|
||||
return errors.New("Failed to process history: " + title + " type:" + v.Type + " trakt id:" + strconv.Itoa(traktId) + " tmdb id:" + strconv.Itoa(tmdbId) + " error:" + "item had no tmdb id")
|
||||
}
|
||||
mapKey := makeTraktMapKey(contentType, tmdbId)
|
||||
mapKey := t.makeTraktMapKey(contentType, tmdbId)
|
||||
if e, ok := toImport[mapKey]; ok {
|
||||
e.WatchedEpisodes = append(toImport[mapKey].WatchedEpisodes, watchedEpisode)
|
||||
toImport[mapKey] = e
|
||||
@@ -378,20 +391,20 @@ func processTraktHistoryItem(v TraktHistory, toImport map[string]ImportRequest)
|
||||
toImport[mapKey] = ImportRequest{
|
||||
Type: contentType,
|
||||
TmdbID: tmdbId,
|
||||
Status: FINISHED,
|
||||
Status: entity.FINISHED,
|
||||
DatesWatched: []time.Time{v.WatchedAt},
|
||||
WatchedEpisodes: []WatchedEpisode{watchedEpisode},
|
||||
WatchedEpisodes: []entity.WatchedEpisode{watchedEpisode},
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// `tmdbId` is for the movie or show (not for episodes).
|
||||
func makeTraktMapKey(ct ContentType, tmdbId int) string {
|
||||
func (t *TraktService) makeTraktMapKey(ct entity.ContentType, tmdbId int) string {
|
||||
return string(ct) + strconv.Itoa(tmdbId)
|
||||
}
|
||||
|
||||
func traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Header, error) {
|
||||
func (t *TraktService) traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Header, error) {
|
||||
base, err := url.Parse("https://api.trakt.tv")
|
||||
if err != nil {
|
||||
return map[string][]string{}, errors.New("failed to parse api uri")
|
||||
@@ -432,20 +445,20 @@ func traktAPIRequest(ep string, p map[string]string, resp interface{}) (http.Hea
|
||||
return res.Header, nil
|
||||
}
|
||||
|
||||
func traktImportWatched(
|
||||
func (t *TraktService) TraktImportWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
traktUsername string,
|
||||
) (TraktImportResponse, error) {
|
||||
jobId, err := addUniqueJob("trakt_import", userId)
|
||||
jobId, err := job.AddUniqueJob("trakt_import", userId)
|
||||
if err != nil {
|
||||
slog.Error("traktSyncWatched: Failed to create a job", "error", err)
|
||||
return TraktImportResponse{}, err
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_RUNNING)
|
||||
|
||||
go startTraktImport(
|
||||
go t.startTraktImport(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
@@ -0,0 +1,60 @@
|
||||
package imprt
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
s *Service
|
||||
ts *TraktService
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
imprt := r.br.Router.Group("/import").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
imprt.POST("", r.ImportContent)
|
||||
imprt.POST("/trakt", r.ImportTrakt)
|
||||
}
|
||||
|
||||
// Import content (the client handle processing data and sends it to us in a uniform way).
|
||||
func (r *Router) ImportContent(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar ImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.s.ImportContent(r.br.DB, 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()})
|
||||
}
|
||||
|
||||
// Import Trakt.
|
||||
func (r *Router) ImportTrakt(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar TraktImportRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.ts.TraktImportWatched(r.br.DB, userId, ar.Username)
|
||||
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()})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
)
|
||||
|
||||
type JellyfinItemSearchResponse struct {
|
||||
@@ -46,21 +48,31 @@ type JFContentFindResponse struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cfg *config.ServerConfig
|
||||
}
|
||||
|
||||
func NewService(cfg *config.ServerConfig) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Jellyfin access middleware, ensures user is a jellyfin user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func JellyfinAccessRequired() gin.HandlerFunc {
|
||||
func (s *Service) JellyfinAccessRequired(cfg *config.ServerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("JellyfinAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
userType := c.MustGet("userType").(entity.UserType)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
if cfg.JELLYFIN_HOST == "" {
|
||||
slog.Error("JellyfinAccessRequired: Request made to login via Jellyfin, but JELLYFIN_HOST has not been configured.")
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != JELLYFIN_USER || userThirdPartyId == "" {
|
||||
if userType != entity.JELLYFIN_USER || userThirdPartyId == "" {
|
||||
slog.Error("JellyfinAccessRequired: User is not a jellyfin user..", "user_type", userType, "user_third_party_id", userThirdPartyId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
@@ -73,13 +85,13 @@ func JellyfinAccessRequired() gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func jellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
|
||||
if Config.JELLYFIN_HOST == "" {
|
||||
func (s *Service) JellyfinAPIRequest(method string, ep string, p map[string]string, username string, userToken string, resp interface{}) error {
|
||||
if s.cfg.JELLYFIN_HOST == "" {
|
||||
slog.Error("jellyfinAPIRequest: JELLYFIN_HOST not configured.")
|
||||
return errors.New("jellyfin not enabled")
|
||||
}
|
||||
slog.Debug("jellyfinAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse(Config.JELLYFIN_HOST)
|
||||
base, err := url.Parse(s.cfg.JELLYFIN_HOST)
|
||||
if err != nil {
|
||||
return errors.New("failed to parse api uri")
|
||||
}
|
||||
@@ -132,9 +144,9 @@ func jellyfinAPIRequest(method string, ep string, p map[string]string, username
|
||||
return nil
|
||||
}
|
||||
|
||||
func jellyfinContentFind(
|
||||
func (s *Service) JellyfinContentFind(
|
||||
userId uint,
|
||||
userType UserType,
|
||||
userType entity.UserType,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
@@ -151,7 +163,7 @@ func jellyfinContentFind(
|
||||
}
|
||||
|
||||
resp := new(JellyfinItemSearchResponse)
|
||||
err := jellyfinAPIRequest(
|
||||
err := s.JellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
@@ -184,7 +196,7 @@ func jellyfinContentFind(
|
||||
for _, i := range resp.Items {
|
||||
if i.ProviderIds.Tmdb == contentTmdbId {
|
||||
ret.HasContent = true
|
||||
ret.Url = Config.JELLYFIN_HOST + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
|
||||
ret.Url = s.cfg.JELLYFIN_HOST + "/web/index.html#!/details?id=" + i.Id + "&serverId=" + i.ServerID
|
||||
}
|
||||
}
|
||||
return *ret, nil
|
||||
@@ -1,10 +1,17 @@
|
||||
package main
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"github.com/sbondCo/Watcharr/feature/job"
|
||||
"github.com/sbondCo/Watcharr/feature/watched"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/episode"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/season"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -34,10 +41,40 @@ type JellyfinSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
type WatchedProvider interface {
|
||||
AddWatched(db *gorm.DB, userId uint, ar watched.WatchedAddRequest, at entity.ActivityType) (entity.Watched, error)
|
||||
}
|
||||
|
||||
type WatchedSeasonProvider interface {
|
||||
AddWatchedSeason(db *gorm.DB, userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
|
||||
}
|
||||
|
||||
type WatchedEpisodeProvider interface {
|
||||
AddWatchedEpisodes(db *gorm.DB, userId uint, ar episode.WatchedEpisodeAddRequest) (episode.WatchedEpisodeAddResponse, error)
|
||||
}
|
||||
|
||||
type SyncService struct {
|
||||
wp WatchedProvider
|
||||
wsp WatchedSeasonProvider
|
||||
wep WatchedEpisodeProvider
|
||||
cfg *config.ServerConfig
|
||||
service *Service
|
||||
}
|
||||
|
||||
func NewSyncService(cfg *config.ServerConfig, service *Service, wp WatchedProvider, wsp WatchedSeasonProvider, wep WatchedEpisodeProvider) *SyncService {
|
||||
return &SyncService{
|
||||
cfg: cfg,
|
||||
service: service,
|
||||
wp: wp,
|
||||
wsp: wsp,
|
||||
wep: wep,
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the jellyfin sync.
|
||||
// Gets each type of media separately from jellyfin and attempts to import them.
|
||||
// Errors are added silently to the job.
|
||||
func startJellyfinSync(
|
||||
func (s *SyncService) startJellyfinSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
@@ -46,9 +83,9 @@ func startJellyfinSync(
|
||||
userThirdPartyAuth string,
|
||||
) {
|
||||
// Get played movies
|
||||
updateJobCurrentTask(jobId, userId, "syncing movies")
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing movies")
|
||||
playedMovies := new(JellyfinItemSearchResponse)
|
||||
err := jellyfinAPIRequest(
|
||||
err := s.service.JellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
@@ -63,7 +100,7 @@ func startJellyfinSync(
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
|
||||
addJobError(jobId, userId, "failed to get jellyfin response for movies")
|
||||
job.AddJobError(jobId, userId, "failed to get jellyfin response for movies")
|
||||
} else {
|
||||
if len(playedMovies.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: User has no played movies.", "user_id", userId)
|
||||
@@ -75,36 +112,36 @@ func startJellyfinSync(
|
||||
// 1. Ensure we have a tmdbId
|
||||
if v.ProviderIds.Tmdb == "" {
|
||||
slog.Error("jellyfinSyncWatched: Movie to import does not have a tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+v.Name)
|
||||
job.AddJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+v.Name)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
job.AddJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing "+v.Name)
|
||||
|
||||
// 2. Imported watched movie
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
w, err := s.wp.AddWatched(db, userId, watched.WatchedAddRequest{
|
||||
Status: entity.FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
ContentType: entity.MOVIE,
|
||||
WatchedDate: v.UserData.LastPlayedDate,
|
||||
}, IMPORTED_WATCHED_JF)
|
||||
}, entity.IMPORTED_WATCHED_JF)
|
||||
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)
|
||||
} else {
|
||||
slog.Error("jellyfinSyncWatched: Movie failed to import.", "movie_name", v.Name, "movie_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (failed when adding to watched list): "+v.Name)
|
||||
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 := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
_, err := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
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)
|
||||
@@ -117,9 +154,9 @@ func startJellyfinSync(
|
||||
|
||||
// Get played series
|
||||
// Can't rely on IsPlayed filter, since we want to get partially played series too.
|
||||
updateJobCurrentTask(jobId, userId, "syncing series")
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing series")
|
||||
allSeries := new(JellyfinItemSearchResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
err = s.service.JellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Users/"+userThirdPartyId+"/Items",
|
||||
map[string]string{
|
||||
@@ -134,7 +171,7 @@ func startJellyfinSync(
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Jellyfin API request failed", "error", err)
|
||||
addJobError(jobId, userId, "failed to get jellyfin response for series")
|
||||
job.AddJobError(jobId, userId, "failed to get jellyfin response for series")
|
||||
} else {
|
||||
if len(allSeries.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: No series found.", "user_id", userId)
|
||||
@@ -153,37 +190,37 @@ func startJellyfinSync(
|
||||
// 1.1. Ensure we have a tmdbId
|
||||
if v.ProviderIds.Tmdb == "" {
|
||||
slog.Error("jellyfinSyncWatched: Series to import does not have a tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (no tmdbId present): "+v.Name)
|
||||
job.AddJobError(jobId, userId, "series could not be imported (no tmdbId present): "+v.Name)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(v.ProviderIds.Tmdb)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Series to import does not have a parseable (to int) tmdb id.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
job.AddJobError(jobId, userId, "series could not be imported (tmdbId was not parseable): "+v.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
updateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing serie "+v.Name)
|
||||
|
||||
// 2. Imported watched series
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
w, err := s.wp.AddWatched(db, userId, watched.WatchedAddRequest{
|
||||
Status: entity.FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
ContentType: entity.SHOW,
|
||||
WatchedDate: v.UserData.LastPlayedDate,
|
||||
}, IMPORTED_WATCHED_JF)
|
||||
}, entity.IMPORTED_WATCHED_JF)
|
||||
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)
|
||||
} else {
|
||||
slog.Error("jellyfinSyncWatched: Series failed to import.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
addJobError(jobId, userId, "series could not be imported (failed when adding to watched list): "+v.Name)
|
||||
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 := addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
_, err := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.IMPORTED_ADDED_WATCHED_JF, CustomDate: &v.UserData.LastPlayedDate})
|
||||
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)
|
||||
@@ -194,7 +231,7 @@ func startJellyfinSync(
|
||||
// 4. Import watched seasons for this serie
|
||||
// Get all show seasons (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
|
||||
seriesSeasons := new(JellyfinSeriesSeasonsResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
err = s.service.JellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Shows/"+v.Id+"/Seasons",
|
||||
map[string]string{
|
||||
@@ -208,7 +245,7 @@ func startJellyfinSync(
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
|
||||
job.AddJobError(jobId, userId, "series seasons could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesSeasons.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
@@ -218,17 +255,17 @@ func startJellyfinSync(
|
||||
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed season.", "series_name", v.Name, "season_num", vs.IndexNumber, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = s.wsp.AddWatchedSeason(db, userId, season.WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.IndexNumber,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_JF,
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
Status: entity.FINISHED,
|
||||
AddActivity: entity.SEASON_ADDED_JF,
|
||||
AddActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series seasons.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
job.AddJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+v.Name+" season "+strconv.Itoa(vs.IndexNumber))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -236,7 +273,7 @@ func startJellyfinSync(
|
||||
// 5. Import watched episodes for this serie
|
||||
// Gets all show episodes (filtering isPlayed doesn't seem to be a thing, so we will have to do that ourselves)
|
||||
seriesEpisodes := new(JellyfinSeriesEpisodesResponse)
|
||||
err = jellyfinAPIRequest(
|
||||
err = s.service.JellyfinAPIRequest(
|
||||
"GET",
|
||||
"/Shows/"+v.Id+"/Episodes",
|
||||
map[string]string{
|
||||
@@ -250,7 +287,7 @@ func startJellyfinSync(
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to fetch series episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
|
||||
job.AddJobError(jobId, userId, "series episodes could not be imported (request failed): "+v.Name)
|
||||
} else if len(seriesEpisodes.Items) <= 0 {
|
||||
slog.Info("jellyfinSyncWatched: Series has no episodes.", "series_name", v.Name, "series_ids", v.ProviderIds, "user_id", userId)
|
||||
} else {
|
||||
@@ -260,18 +297,18 @@ func startJellyfinSync(
|
||||
slog.Debug("jellyfinSyncWatched: Skipping import of unplayed episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.ParentIndexNumber)+" episode "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing "+v.Name+" season "+strconv.Itoa(vs.ParentIndexNumber)+" episode "+strconv.Itoa(vs.IndexNumber))
|
||||
_, err = s.wep.AddWatchedEpisodes(db, userId, episode.WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndexNumber,
|
||||
EpisodeNumber: vs.IndexNumber,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_JF,
|
||||
addActivityDate: vs.UserData.LastPlayedDate,
|
||||
Status: entity.FINISHED,
|
||||
AddActivity: entity.EPISODE_ADDED_JF,
|
||||
AddActivityDate: vs.UserData.LastPlayedDate,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to import series episode.", "series_name", v.Name, "season_num", vs.ParentIndexNumber, "episode_num", vs.IndexNumber, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
|
||||
job.AddJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+v.Name+" "+vs.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,26 +316,26 @@ func startJellyfinSync(
|
||||
}
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_DONE)
|
||||
}
|
||||
|
||||
func jellyfinSyncWatched(
|
||||
func (s *SyncService) jellyfinSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userType UserType,
|
||||
userType entity.UserType,
|
||||
username string,
|
||||
userThirdPartyId string,
|
||||
userThirdPartyAuth string,
|
||||
) (JellyfinSyncResponse, error) {
|
||||
jobId, err := addJob("jf_sync", userId)
|
||||
jobId, err := job.AddJob("jf_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("jellyfinSyncWatched: Failed to create a job", "error", err)
|
||||
return JellyfinSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_RUNNING)
|
||||
|
||||
go startJellyfinSync(
|
||||
go s.startJellyfinSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
@@ -0,0 +1,72 @@
|
||||
package jellyfin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
s *Service
|
||||
syncService *SyncService
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter, s *Service, syncService *SyncService) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
s: s,
|
||||
syncService: syncService,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
jf := r.br.Router.Group("/jellyfin").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg), r.s.JellyfinAccessRequired(r.br.Cfg))
|
||||
|
||||
// Check if jf has item
|
||||
jf.GET("/:type/:name/:tmdbId", r.GetFindContent)
|
||||
// Sync users jellyfin watched items to watchlist
|
||||
jf.GET("/sync", r.GetSync)
|
||||
}
|
||||
|
||||
// Check if jf has item
|
||||
func (r *Router) GetFindContent(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userType := c.MustGet("userType").(entity.UserType)
|
||||
username := c.MustGet("username").(string)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
response, err := r.s.JellyfinContentFind(
|
||||
userId,
|
||||
userType,
|
||||
username,
|
||||
userThirdPartyId,
|
||||
userThirdPartyAuth,
|
||||
c.Param("type"),
|
||||
c.Param("name"),
|
||||
c.Param("tmdbId"),
|
||||
)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Sync users jellyfin watched items to watchlist
|
||||
func (r *Router) GetSync(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userType := c.MustGet("userType").(entity.UserType)
|
||||
username := c.MustGet("username").(string)
|
||||
userThirdPartyId := c.MustGet("userThirdPartyId").(string)
|
||||
userThirdPartyAuth := c.MustGet("userThirdPartyAuth").(string)
|
||||
response, err := r.syncService.jellyfinSyncWatched(r.br.DB, userId, userType, username, userThirdPartyId, userThirdPartyAuth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -3,12 +3,14 @@
|
||||
// When starting a job elsewhere, we should first add a job here as active to get an `id`,
|
||||
// this id should be used to update the active job so the client can request job status updates.
|
||||
|
||||
package main
|
||||
package job
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
|
||||
type JobStatus string
|
||||
@@ -39,8 +41,8 @@ var activeJobs = make(map[string]*Job)
|
||||
// Add a job to our activeJobs map.
|
||||
// Returns id of job on success, or error if failed to add.
|
||||
// Only return safe errors for display to users, log serious errors.
|
||||
func addJob(name string, userId uint) (string, error) {
|
||||
idk, err := generateString(8)
|
||||
func AddJob(name string, userId uint) (string, error) {
|
||||
idk, err := util.GenerateString(8)
|
||||
if err != nil {
|
||||
slog.Error("addJob: Failed to generate a job id!", "error", err)
|
||||
return "", errors.New("failed to generate a job id, please try again")
|
||||
@@ -60,7 +62,7 @@ func addJob(name string, userId uint) (string, error) {
|
||||
|
||||
// Add a job, but only if one with the same `name` isn't already running.
|
||||
// Only return safe errors for display to users.
|
||||
func addUniqueJob(name string, userId uint) (string, error) {
|
||||
func AddUniqueJob(name string, userId uint) (string, error) {
|
||||
found := false
|
||||
for _, v := range activeJobs {
|
||||
if v.UserId == userId && v.Name == name && (v.Status == JOB_CREATED || v.Status == JOB_RUNNING) {
|
||||
@@ -71,10 +73,10 @@ func addUniqueJob(name string, userId uint) (string, error) {
|
||||
if found {
|
||||
return "", errors.New("a job of this type is already running, please wait for the existing job to finish")
|
||||
}
|
||||
return addJob(name, userId)
|
||||
return AddJob(name, userId)
|
||||
}
|
||||
|
||||
func rmJob(id string, userId uint) {
|
||||
func RmJob(id string, userId uint) {
|
||||
slog.Debug("rmJob: Removing a job.", "id", id)
|
||||
v, ok := activeJobs[id]
|
||||
if ok && v.UserId == userId {
|
||||
@@ -87,7 +89,7 @@ func rmJob(id string, userId uint) {
|
||||
|
||||
// Get a job.
|
||||
// Returns job if found, otherwise errors if job does not exist.
|
||||
func getJob(id string, userId uint) (*Job, error) {
|
||||
func GetJob(id string, userId uint) (*Job, error) {
|
||||
j, ok := activeJobs[id]
|
||||
if ok {
|
||||
// Ensure user requesting a job, owns the job.
|
||||
@@ -101,8 +103,8 @@ func getJob(id string, userId uint) (*Job, error) {
|
||||
}
|
||||
|
||||
// Update a jobs status.
|
||||
func updateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
j, err := getJob(id, userId)
|
||||
func UpdateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
j, err := GetJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobStatus: Failed!", "status", status, "error", err)
|
||||
return err
|
||||
@@ -114,15 +116,15 @@ func updateJobStatus(id string, userId uint, status JobStatus) error {
|
||||
go func() {
|
||||
time.Sleep(30 * time.Minute)
|
||||
slog.Debug("updateJobStatus: Job done. waited 30m.. removing job now.", "id", id)
|
||||
rmJob(id, userId)
|
||||
RmJob(id, userId)
|
||||
}()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update a jobs current task.
|
||||
func updateJobCurrentTask(id string, userId uint, ct string) error {
|
||||
j, err := getJob(id, userId)
|
||||
func UpdateJobCurrentTask(id string, userId uint, ct string) error {
|
||||
j, err := GetJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobCurrentTask: Failed!", "ct", ct, "error", err)
|
||||
return err
|
||||
@@ -132,8 +134,8 @@ func updateJobCurrentTask(id string, userId uint, ct string) error {
|
||||
}
|
||||
|
||||
// Add an error to a job.
|
||||
func addJobError(id string, userId uint, e string) error {
|
||||
j, err := getJob(id, userId)
|
||||
func AddJobError(id string, userId uint, e string) error {
|
||||
j, err := GetJob(id, userId)
|
||||
if err != nil {
|
||||
slog.Error("updateJobCurrentTask: Failed!", "e", e, "error", err)
|
||||
return err
|
||||
@@ -0,0 +1,36 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
job := r.br.Router.Group("/job").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
// Uses wildcard so it still works in cases where the job id includes a /.
|
||||
// (yes i changed this instead of not allowing a / when we generate a job id becuz easier)
|
||||
job.GET("/*id", r.GetJobById)
|
||||
}
|
||||
|
||||
func (r *Router) GetJobById(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
// When we get id param, don't include first letter, which will be the beginning '/'.
|
||||
response, err := GetJob(c.Param("id")[1:], userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, *response)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package plex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -8,8 +8,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
)
|
||||
|
||||
type PlexLoginRequest struct {
|
||||
@@ -335,40 +334,17 @@ type PlexClientResources []struct {
|
||||
} `json:"connections"`
|
||||
}
|
||||
|
||||
// Plex access middleware, ensures user is a Plex user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func PlexAccessRequired(db *gorm.DB) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(UserType)
|
||||
if Config.PLEX_HOST == "" || Config.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != PLEX_USER {
|
||||
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
userPlexService := new(UserServices)
|
||||
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
|
||||
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
|
||||
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
c.Set("plexAuthToken", userPlexService.AuthToken)
|
||||
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
|
||||
type Service struct {
|
||||
cfg *config.ServerConfig
|
||||
}
|
||||
|
||||
func NewService(cfg *config.ServerConfig) *Service {
|
||||
return &Service{
|
||||
cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func getPlexIdentity(host string) (PlexIdentity, error) {
|
||||
func (s *Service) GetPlexIdentity(host string) (PlexIdentity, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", host+"/identity", nil)
|
||||
if err != nil {
|
||||
@@ -392,7 +368,7 @@ func getPlexIdentity(host string) (PlexIdentity, error) {
|
||||
return pi, nil
|
||||
}
|
||||
|
||||
func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
func (s *Service) FetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://plex.tv/users/account.json", nil)
|
||||
if err != nil {
|
||||
@@ -419,32 +395,32 @@ func fetchPlexAccountFromToken(token string) (PlexUser, error) {
|
||||
}
|
||||
|
||||
// Update plex host setting
|
||||
func updateConfigPlexHost(v string) (PlexHostConfigUpdateResponse, error) {
|
||||
Config.PLEX_HOST = v
|
||||
if Config.PLEX_HOST != "" {
|
||||
pi, err := getPlexIdentity(Config.PLEX_HOST)
|
||||
func (s *Service) UpdateConfigPlexHost(cfg *config.ServerConfig, v string) (PlexHostConfigUpdateResponse, error) {
|
||||
cfg.PLEX_HOST = v
|
||||
if cfg.PLEX_HOST != "" {
|
||||
pi, err := s.GetPlexIdentity(cfg.PLEX_HOST)
|
||||
if err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to get plex server identity!", "error", err)
|
||||
slog.Error("UpdateConfigPlexHost: Failed to get plex server identity!", "error", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to get Plex server identity. Please try setting the Plex Host again or setting PLEX_MACHINE_ID manually in your config file")
|
||||
}
|
||||
if pi.MediaContainer.MachineIdentifier == "" {
|
||||
slog.Error("updateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
|
||||
slog.Error("UpdateConfigPlexHost: Plex server identity response had no machine id!", "response", pi)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("got Plex server identity, but no machine id was found")
|
||||
}
|
||||
Config.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
|
||||
cfg.PLEX_MACHINE_ID = pi.MediaContainer.MachineIdentifier
|
||||
} else {
|
||||
Config.PLEX_MACHINE_ID = ""
|
||||
cfg.PLEX_MACHINE_ID = ""
|
||||
}
|
||||
if err := writeConfig(); err != nil {
|
||||
slog.Error("updateConfigPlexHost: Failed to write updated config to file!", "err", err)
|
||||
if err := cfg.Write(); err != nil {
|
||||
slog.Error("UpdateConfigPlexHost: Failed to write updated config to file!", "err", err)
|
||||
return PlexHostConfigUpdateResponse{}, errors.New("failed to write config")
|
||||
}
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: Config.PLEX_MACHINE_ID}, nil
|
||||
return PlexHostConfigUpdateResponse{PLEX_MACHINE_ID: cfg.PLEX_MACHINE_ID}, nil
|
||||
}
|
||||
|
||||
func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
func (s *Service) GetPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections", nil)
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections", nil)
|
||||
if err != nil {
|
||||
return PlexLibrariesResponse{}, err
|
||||
}
|
||||
@@ -467,9 +443,9 @@ func getPlexLibraries(plexAuth string) (PlexLibrariesResponse, error) {
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
func (s *Service) GetPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/sections/"+libraryKey+"/all?includeGuids=1", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemsResponse{}, err
|
||||
}
|
||||
@@ -492,9 +468,9 @@ func getPlexLibraryItems(plexAuth string, libraryKey string) (PlexLibraryItemsRe
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
func (s *Service) GetPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryItemSeasonsResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/children", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemSeasonsResponse{}, err
|
||||
}
|
||||
@@ -517,9 +493,9 @@ func getPlexLibraryItemSeasons(plexAuth string, ratingKey string) (PlexLibraryIt
|
||||
return pl, nil
|
||||
}
|
||||
|
||||
func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
func (s *Service) GetPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryItemEpisodesResponse, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", Config.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
req, err := http.NewRequest("GET", s.cfg.PLEX_HOST+"/library/metadata/"+ratingKey+"/allLeaves", nil)
|
||||
if err != nil {
|
||||
return PlexLibraryItemEpisodesResponse{}, err
|
||||
}
|
||||
@@ -545,7 +521,7 @@ func getPlexLibraryItemEpisodes(plexAuth string, ratingKey string) (PlexLibraryI
|
||||
// Gets users auth token for local plex server,
|
||||
// so they can authenticate against it for api requests.
|
||||
// If no auth token is returned or errored, assume user doesn't have access to home plex server library.
|
||||
func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
func (s *Service) GetPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, error) {
|
||||
httpClient := &http.Client{}
|
||||
req, err := http.NewRequest("GET", "https://clients.plex.tv/api/v2/resources", nil)
|
||||
if err != nil {
|
||||
@@ -570,7 +546,7 @@ func getPlexHomeServerAuthToken(plexAuth string, userClientId string) (string, e
|
||||
}
|
||||
authToken := ""
|
||||
for _, v := range pl {
|
||||
if v.ClientIdentifier == Config.PLEX_MACHINE_ID {
|
||||
if v.ClientIdentifier == s.cfg.PLEX_MACHINE_ID {
|
||||
slog.Debug("getPlexHomeServerAuthToken: Found entry with clientIdentifier matching home server machine id.")
|
||||
if v.AccessToken == "" {
|
||||
slog.Error("getPlexHomeServerAuthToken: Matching entry has no AccessToken!")
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package plex
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -7,6 +7,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"github.com/sbondCo/Watcharr/feature/job"
|
||||
"github.com/sbondCo/Watcharr/feature/watched"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/episode"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/season"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -14,30 +20,55 @@ type PlexSyncResponse struct {
|
||||
JobId string `json:"jobId"`
|
||||
}
|
||||
|
||||
type WatchedProvider interface {
|
||||
AddWatched(db *gorm.DB, userId uint, ar watched.WatchedAddRequest, at entity.ActivityType) (entity.Watched, error)
|
||||
}
|
||||
|
||||
type WatchedSeasonProvider interface {
|
||||
AddWatchedSeason(db *gorm.DB, userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
|
||||
}
|
||||
|
||||
type WatchedEpisodeProvider interface {
|
||||
AddWatchedEpisodes(db *gorm.DB, userId uint, ar episode.WatchedEpisodeAddRequest) (episode.WatchedEpisodeAddResponse, error)
|
||||
}
|
||||
|
||||
type SyncService struct {
|
||||
s *Service
|
||||
wp WatchedProvider
|
||||
wsp WatchedSeasonProvider
|
||||
wep WatchedEpisodeProvider
|
||||
}
|
||||
|
||||
func NewSyncService(s *Service, wp WatchedProvider, wsp WatchedSeasonProvider, wep WatchedEpisodeProvider) *SyncService {
|
||||
return &SyncService{
|
||||
s: s,
|
||||
}
|
||||
}
|
||||
|
||||
// Perform a Plex sync.
|
||||
// Errors are added silently to the job.
|
||||
func startPlexSync(
|
||||
func (s *SyncService) startPlexSync(
|
||||
db *gorm.DB,
|
||||
jobId string,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) {
|
||||
updateJobCurrentTask(jobId, userId, "fetching libraries")
|
||||
libraries, err := getPlexLibraries(userPlexLocalAuth)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "fetching libraries")
|
||||
libraries, err := s.s.GetPlexLibraries(userPlexLocalAuth)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch libraries", "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to get plex libraries")
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
job.AddJobError(jobId, userId, "failed to get plex libraries")
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_DONE)
|
||||
return
|
||||
}
|
||||
for _, library := range libraries.MediaContainer.Directory {
|
||||
slog.Debug("plexSyncWatched: Processing a library", "library_title", library.Title, "library_type", library.Type, "user_id", userId)
|
||||
if library.Type == "movie" {
|
||||
updateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
|
||||
movies, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "importing movies from "+library.Title)
|
||||
movies, err := s.s.GetPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch movies from library", "library", library.Key, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
|
||||
job.AddJobError(jobId, userId, "failed to fetch movies from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, movie := range movies.MediaContainer.Metadata {
|
||||
@@ -46,13 +77,13 @@ func startPlexSync(
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched movie:", "movie_name", movie.Title, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "importing movie "+movie.Title)
|
||||
slog.Info("plexSyncWatched: Importing movie.", "movie_name", movie.Title, "user_id", userId)
|
||||
|
||||
// Find tmdb id
|
||||
if len(movie.Guid) <= 0 {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have any external guids.", "movie_name", movie.Title, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
|
||||
job.AddJobError(jobId, userId, "movie could not be imported (no external ids present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbIdStr := ""
|
||||
@@ -64,37 +95,37 @@ func startPlexSync(
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
|
||||
job.AddJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Movie to import does not have a parseable (to int) tmdb id.", "movie_name", movie.Title, "tmdb_id_str", tmdbIdStr, "movie_id", movie.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
|
||||
job.AddJobError(jobId, userId, "movie could not be imported (tmdbId was not parseable): "+movie.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(movie.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
w, err := s.wp.AddWatched(db, userId, watched.WatchedAddRequest{
|
||||
Status: entity.FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: MOVIE,
|
||||
ContentType: entity.MOVIE,
|
||||
Rating: float64(movie.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
}, entity.IMPORTED_WATCHED_PLEX)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
slog.Error("plexSyncWatched: Failed to add movie as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add movie "+movie.Title)
|
||||
job.AddJobError(jobId, userId, "failed to add movie "+movie.Title)
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
_, err := activity.AddActivity(db, userId, activity.ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -105,11 +136,11 @@ func startPlexSync(
|
||||
}
|
||||
}
|
||||
} else if library.Type == "show" {
|
||||
updateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
|
||||
shows, err := getPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "importing tv shows from "+library.Title)
|
||||
shows, err := s.s.GetPlexLibraryItems(userPlexLocalAuth, library.Key)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch shows from library", "library", library.Key, "error", err)
|
||||
addJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
|
||||
job.AddJobError(jobId, userId, "failed to fetch shows from library "+library.Key)
|
||||
continue
|
||||
}
|
||||
for _, show := range shows.MediaContainer.Metadata {
|
||||
@@ -119,7 +150,7 @@ func startPlexSync(
|
||||
slog.Debug("plexSyncWatched: Skipping unwatched show:", "show_name", show.Title, "leaf_count", show.LeafCount, "viewed_leaf_count", show.ViewedLeafCount, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "importing show "+show.Title)
|
||||
job.UpdateJobCurrentTask(jobId, userId, "importing show "+show.Title)
|
||||
slog.Info("plexSyncWatched: Importing show.", "show_name", show.Title, "user_id", userId)
|
||||
|
||||
tmdbIdStr := ""
|
||||
@@ -131,37 +162,37 @@ func startPlexSync(
|
||||
}
|
||||
if tmdbIdStr == "" {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
|
||||
job.AddJobError(jobId, userId, "movie could not be imported (no tmdbId present): "+show.Title)
|
||||
continue
|
||||
}
|
||||
tmdbId, err := strconv.Atoi(tmdbIdStr)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Show to import does not have a parseable (to int) tmdb id.", "show_name", show.Title, "tmdb_id_str", tmdbIdStr, "show_id", show.GUID, "user_id", userId)
|
||||
addJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
|
||||
job.AddJobError(jobId, userId, "show could not be imported (tmdbId was not parseable): "+show.Title)
|
||||
continue
|
||||
}
|
||||
|
||||
lastViewedAt := time.Unix(show.LastViewedAt, 0)
|
||||
w, err := addWatched(db, userId, WatchedAddRequest{
|
||||
Status: FINISHED,
|
||||
w, err := s.wp.AddWatched(db, userId, watched.WatchedAddRequest{
|
||||
Status: entity.FINISHED,
|
||||
ContentID: tmdbId,
|
||||
ContentType: SHOW,
|
||||
ContentType: entity.SHOW,
|
||||
Rating: float64(show.UserRating),
|
||||
WatchedDate: lastViewedAt,
|
||||
}, IMPORTED_WATCHED_PLEX)
|
||||
}, entity.IMPORTED_WATCHED_PLEX)
|
||||
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)
|
||||
} else {
|
||||
slog.Error("plexSyncWatched: Failed to add show as watched", "error", err)
|
||||
addJobError(jobId, userId, "failed to add show "+show.Title)
|
||||
job.AddJobError(jobId, userId, "failed to add show "+show.Title)
|
||||
}
|
||||
} else {
|
||||
// 3. Add IMPORTED_ADDED_WATCHED_PLEX activity
|
||||
if !lastViewedAt.IsZero() {
|
||||
_, err := addActivity(db, userId, ActivityAddRequest{
|
||||
_, err := activity.AddActivity(db, userId, activity.ActivityAddRequest{
|
||||
WatchedID: w.ID,
|
||||
Type: IMPORTED_ADDED_WATCHED_PLEX,
|
||||
Type: entity.IMPORTED_ADDED_WATCHED_PLEX,
|
||||
CustomDate: &lastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -172,10 +203,10 @@ func startPlexSync(
|
||||
}
|
||||
|
||||
// Import watched seasons for this serie
|
||||
seriesSeasons, err := getPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
|
||||
seriesSeasons, err := s.s.GetPlexLibraryItemSeasons(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
|
||||
job.AddJobError(jobId, userId, "series seasons could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesSeasons.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no seasons.", "series_name", show.Title, "serie_ids", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
@@ -185,30 +216,30 @@ func startPlexSync(
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed season.", "series_name", show.Title, "season_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
var seasonLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
seasonLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedSeason(db, userId, WatchedSeasonAddRequest{
|
||||
_, err = s.wsp.AddWatchedSeason(db, userId, season.WatchedSeasonAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: SEASON_ADDED_PLEX,
|
||||
addActivityDate: seasonLastViewedAt,
|
||||
Status: entity.FINISHED,
|
||||
AddActivity: entity.SEASON_ADDED_PLEX,
|
||||
AddActivityDate: seasonLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series seasons.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
job.AddJobError(jobId, userId, "series season could not be imported (addWatchedSeason request failed): "+show.Title+" season "+strconv.Itoa(vs.Index))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Import watched episodes for this serie
|
||||
seriesEpisodes, err := getPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
|
||||
seriesEpisodes, err := s.s.GetPlexLibraryItemEpisodes(userPlexLocalAuth, show.RatingKey)
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to fetch series episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
|
||||
job.AddJobError(jobId, userId, "series episodes could not be imported (request failed): "+show.Title)
|
||||
} else if len(seriesEpisodes.MediaContainer.Metadata) <= 0 {
|
||||
slog.Info("plexSyncWatched: Series has no episodes.", "series_name", show.Title, "series_id", show.GUID, "user_id", userId)
|
||||
} else {
|
||||
@@ -218,45 +249,45 @@ func startPlexSync(
|
||||
slog.Debug("plexSyncWatched: Skipping import of unplayed episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId)
|
||||
continue
|
||||
}
|
||||
updateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
|
||||
job.UpdateJobCurrentTask(jobId, userId, "syncing "+show.Title+" season "+strconv.Itoa(vs.ParentIndex)+" episode "+strconv.Itoa(vs.Index))
|
||||
var episodeLastViewedAt time.Time
|
||||
if vs.LastViewedAt != 0 {
|
||||
episodeLastViewedAt = time.Unix(vs.LastViewedAt, 0)
|
||||
}
|
||||
_, err = addWatchedEpisodes(db, userId, WatchedEpisodeAddRequest{
|
||||
_, err = s.wep.AddWatchedEpisodes(db, userId, episode.WatchedEpisodeAddRequest{
|
||||
WatchedID: w.ID,
|
||||
SeasonNumber: vs.ParentIndex,
|
||||
EpisodeNumber: vs.Index,
|
||||
Status: FINISHED,
|
||||
addActivity: EPISODE_ADDED_PLEX,
|
||||
addActivityDate: episodeLastViewedAt,
|
||||
Status: entity.FINISHED,
|
||||
AddActivity: entity.EPISODE_ADDED_PLEX,
|
||||
AddActivityDate: episodeLastViewedAt,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("plexSyncWatched: Failed to import series episode.", "series_name", show.Title, "season_num", vs.ParentIndex, "episode_num", vs.Index, "user_id", userId, "error", err)
|
||||
addJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
|
||||
job.AddJobError(jobId, userId, "series episode could not be imported (addWatchedEpisode request failed): "+show.Title+" "+vs.Title)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
updateJobStatus(jobId, userId, JOB_DONE)
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_DONE)
|
||||
}
|
||||
|
||||
func plexSyncWatched(
|
||||
func (s *SyncService) PlexSyncWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
userPlexLocalAuth string,
|
||||
) (PlexSyncResponse, error) {
|
||||
jobId, err := addJob("plex_sync", userId)
|
||||
jobId, err := job.AddJob("plex_sync", userId)
|
||||
if err != nil {
|
||||
slog.Error("startPlexSync: Failed to create a job", "error", err)
|
||||
return PlexSyncResponse{}, errors.New("failed to create job")
|
||||
}
|
||||
|
||||
updateJobStatus(jobId, userId, JOB_RUNNING)
|
||||
job.UpdateJobStatus(jobId, userId, job.JOB_RUNNING)
|
||||
|
||||
go startPlexSync(
|
||||
go s.startPlexSync(
|
||||
db,
|
||||
jobId,
|
||||
userId,
|
||||
@@ -0,0 +1,43 @@
|
||||
package plexmiddleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Plex access middleware, ensures user is a Plex user.
|
||||
// To be ran after AuthRequired middleware with extra data.
|
||||
func PlexAccessRequired(db *gorm.DB, cfg *config.ServerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
slog.Debug("PlexAccessRequired middleware hit", "user_id", userId)
|
||||
userType := c.MustGet("userType").(entity.UserType)
|
||||
if cfg.PLEX_HOST == "" || cfg.PLEX_MACHINE_ID == "" {
|
||||
slog.Error("PlexAccessRequired: Plex has not been configured.", "user_id", userId)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userType != entity.PLEX_USER {
|
||||
slog.Error("PlexAccessRequired: User is not a Plex user..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
userPlexService := new(entity.UserServices)
|
||||
if res := db.Where("user_id = ? AND name = ?", userId, "plex").Take(&userPlexService); res.Error != nil {
|
||||
slog.Error("PlexAccessRequired: Failed when attempting to get users plex service integration..", "user_id", userId, "user_type", userType)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
if userPlexService.ClientID == "" || userPlexService.AuthToken == "" || userPlexService.AuthToken2 == "" {
|
||||
slog.Error("PlexAccessRequired: User has missing details from service (clientId, authToken or authToken2)..", "user_id", userId, "client_id", userPlexService.ClientID)
|
||||
c.AbortWithStatus(401)
|
||||
return
|
||||
}
|
||||
c.Set("plexAuthToken", userPlexService.AuthToken)
|
||||
c.Set("plexLocalAuthToken", userPlexService.AuthToken2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package plex
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/feature/plex/plexmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
ss *SyncService
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter, ss *SyncService) *Router {
|
||||
return &Router{br, ss}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
plex := r.br.Router.Group("/plex").
|
||||
Use(
|
||||
authmiddleware.AuthRequired(r.br.DB, r.br.Cfg),
|
||||
plexmiddleware.PlexAccessRequired(r.br.DB, r.br.Cfg),
|
||||
)
|
||||
|
||||
// Sync users plex watched items to watchlist
|
||||
plex.GET("/sync", r.GetSync)
|
||||
}
|
||||
|
||||
// Sync users plex watched items to watchlist
|
||||
func (r *Router) GetSync(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
userPlexLocalAuth := c.MustGet("plexLocalAuthToken").(string)
|
||||
response, err := r.ss.PlexSyncWatched(r.br.DB, userId, userPlexLocalAuth)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package profile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -18,14 +19,14 @@ type Profile struct {
|
||||
}
|
||||
|
||||
// Check if content has been previsouly watched by looking for related activity.
|
||||
func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
func hasBeenPreviouslyWatched(a *[]entity.Activity) bool {
|
||||
wp := false
|
||||
var relatedActivity []Activity
|
||||
var relatedActivity []entity.Activity
|
||||
for _, v := range *a {
|
||||
if v.Type == ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == IMPORTED_WATCHED ||
|
||||
v.Type == STATUS_CHANGED {
|
||||
if v.Type == entity.ADDED_WATCHED ||
|
||||
v.Type == entity.IMPORTED_ADDED_WATCHED ||
|
||||
v.Type == entity.IMPORTED_WATCHED ||
|
||||
v.Type == entity.STATUS_CHANGED {
|
||||
relatedActivity = append(relatedActivity, v)
|
||||
}
|
||||
}
|
||||
@@ -33,10 +34,10 @@ func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
return false
|
||||
}
|
||||
for _, ra := range relatedActivity {
|
||||
if ra.Type == IMPORTED_ADDED_WATCHED {
|
||||
if ra.Type == entity.IMPORTED_ADDED_WATCHED {
|
||||
wp = true
|
||||
break
|
||||
} else if ra.Type == ADDED_WATCHED || ra.Type == IMPORTED_WATCHED {
|
||||
} else if ra.Type == entity.ADDED_WATCHED || ra.Type == entity.IMPORTED_WATCHED {
|
||||
if ra.Data == "" {
|
||||
continue
|
||||
}
|
||||
@@ -52,7 +53,7 @@ func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if ra.Type == STATUS_CHANGED {
|
||||
} else if ra.Type == entity.STATUS_CHANGED {
|
||||
if ra.Data == "FINISHED" {
|
||||
wp = true
|
||||
break
|
||||
@@ -64,14 +65,14 @@ func hasBeenPreviouslyWatched(a *[]Activity) bool {
|
||||
|
||||
// Gets any data required for profile page
|
||||
func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
user := new(User)
|
||||
res := db.Model(&User{}).Where("id = ?", userId).Take(&user)
|
||||
user := new(entity.User)
|
||||
res := db.Model(&entity.User{}).Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get profile:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get profile")
|
||||
}
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
watched := new([]entity.Watched)
|
||||
res = db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("Profile: Failed to get watched for processing:", "error", res.Error.Error())
|
||||
return Profile{}, errors.New("failed to get watched for processing")
|
||||
@@ -84,7 +85,7 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
)
|
||||
for _, w := range *watched {
|
||||
isFinished := false
|
||||
if w.Status == FINISHED {
|
||||
if w.Status == entity.FINISHED {
|
||||
isFinished = true
|
||||
} else if *user.IncludePreviouslyWatched && hasBeenPreviouslyWatched(&w.Activity) {
|
||||
// If status is not finished and user has IncludePreviouslyWatched enabled,
|
||||
@@ -96,7 +97,7 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
continue
|
||||
}
|
||||
c := *w.Content
|
||||
if c.Type == SHOW {
|
||||
if c.Type == entity.SHOW {
|
||||
showsWatched++
|
||||
// This aint a science, just a very inaccurate guesstimate.
|
||||
if c.NumberOfEpisodes != 0 {
|
||||
@@ -107,7 +108,7 @@ func getProfile(db *gorm.DB, userId uint) (Profile, error) {
|
||||
showsWatchedRuntime += showRuntime * c.NumberOfEpisodes
|
||||
slog.Debug("calcualted", "show", c.Title, "runti", showRuntime*c.NumberOfEpisodes)
|
||||
}
|
||||
} else if c.Type == MOVIE {
|
||||
} else if c.Type == entity.MOVIE {
|
||||
moviesWatched++
|
||||
moviesWatchedRuntime += c.Runtime
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package profile
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
profile := r.br.Router.Group("/profile").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
// Get user profile details
|
||||
profile.GET("", r.GetProfile)
|
||||
}
|
||||
|
||||
// Get user profile details
|
||||
func (r *Router) GetProfile(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getProfile(r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/feature/plex"
|
||||
"github.com/sbondCo/Watcharr/feature/user"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type PlexProvider interface {
|
||||
UpdateConfigPlexHost(cfg *config.ServerConfig, v string) (plex.PlexHostConfigUpdateResponse, error)
|
||||
}
|
||||
|
||||
type TrustedHeaderAuthProvider interface {
|
||||
SetTrustedHeaderAuthSetting(has config.TrustedHeaderAuthSetting) error
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
plexProvider PlexProvider
|
||||
trustedHeaderAuthProvider TrustedHeaderAuthProvider
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
br *router.BaseRouter,
|
||||
plexProvider PlexProvider,
|
||||
trustedHeaderAuthProvider TrustedHeaderAuthProvider,
|
||||
) *Router {
|
||||
return &Router{
|
||||
br,
|
||||
plexProvider,
|
||||
trustedHeaderAuthProvider,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
server := r.br.Router.Group("/server").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg), authmiddleware.AdminRequired())
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
server.GET("/config", r.GetConfig)
|
||||
// Update config
|
||||
server.POST("/config", r.UpdateConfig)
|
||||
// Update plex host config
|
||||
server.POST("/config/plex_host", r.UpdateConfigPlexHost)
|
||||
// Get server stats
|
||||
server.GET("/stats", cache.CachePage(r.br.MemStore, time.Minute*5, r.GetStats))
|
||||
// Get all server users (for manage users page)
|
||||
server.GET("/users", r.GetAllUsers)
|
||||
// Edit a user (for manage users page)
|
||||
server.POST("/users/:id", r.UpdateManageUser)
|
||||
}
|
||||
|
||||
// Get server config (minus very sensitive fields, like JWT_SECRET)
|
||||
func (r *Router) GetConfig(c *gin.Context) {
|
||||
// s should be provided when asking for the value of just one setting.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
val, err := r.br.Cfg.Get(s)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, val)
|
||||
return
|
||||
}
|
||||
// Return new ServerConfig with only the fields we want to show in settings ui
|
||||
c.JSON(http.StatusOK, r.br.Cfg.GetSafe())
|
||||
}
|
||||
|
||||
// Update config
|
||||
func (r *Router) UpdateConfig(c *gin.Context) {
|
||||
// If query param `s` provided, handle specific setting.
|
||||
// In this case, request body should be new setting value.
|
||||
s := c.Query("s")
|
||||
if s != "" {
|
||||
switch s {
|
||||
case "HEADER_AUTH":
|
||||
var ur config.TrustedHeaderAuthSetting
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
err = r.trustedHeaderAuthProvider.SetTrustedHeaderAuthSetting(ur)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: "unsupported setting"})
|
||||
return
|
||||
}
|
||||
// No `s` param.. handle normally with `updateConfig` func.
|
||||
var ur router.KeyValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := r.br.Cfg.UpdateConfig(ur.Key, ur.Value)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Update plex host config
|
||||
func (r *Router) UpdateConfigPlexHost(c *gin.Context) {
|
||||
var ur router.ValueRequest
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
resp, err := r.plexProvider.UpdateConfigPlexHost(r.br.Cfg, ur.Value.(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Get server stats
|
||||
func (r *Router) GetStats(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, getServerStats(r.br.DB))
|
||||
}
|
||||
|
||||
// Get all server users (for manage users page)
|
||||
func (r *Router) GetAllUsers(c *gin.Context) {
|
||||
resp, err := user.GetAll(r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// Edit a user (for manage users page)
|
||||
func (r *Router) UpdateManageUser(c *gin.Context) {
|
||||
userId, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("/users/:id failed to parse id as a uint", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: "failed to parse id"})
|
||||
return
|
||||
}
|
||||
var ur user.UpdateUserRequest
|
||||
err = c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
err := user.Manage(r.br.DB, uint(userId), ur)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ServerStats struct {
|
||||
Users int64 `json:"users"`
|
||||
PrivateUsers int64 `json:"privateUsers"`
|
||||
WatchedMovies int64 `json:"watchedMovies"`
|
||||
WatchedShows int64 `json:"watchedShows"`
|
||||
WatchedSeasons int64 `json:"watchedSeasons"`
|
||||
MostWatchedMovie entity.Content `json:"mostWatchedMovie"`
|
||||
MostWatchedShow entity.Content `json:"mostWatchedShow"`
|
||||
Activities int64 `json:"activities"`
|
||||
}
|
||||
|
||||
// Collect and return server stats
|
||||
// I cant sql so this the best yall gettin
|
||||
func getServerStats(db *gorm.DB) ServerStats {
|
||||
stats := ServerStats{}
|
||||
// User counts.
|
||||
resp := db.
|
||||
Model(&entity.User{}).
|
||||
Count(&stats.Users).
|
||||
Where("private = 1").
|
||||
Count(&stats.PrivateUsers)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Users query failed", "error", resp.Error)
|
||||
}
|
||||
// Watched seasons count.
|
||||
resp = db.Model(&entity.WatchedSeason{}).Count(&stats.WatchedSeasons)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedSeasons query failed", "error", resp.Error)
|
||||
}
|
||||
// Activities count.
|
||||
resp = db.Model(&entity.Activity{}).Count(&stats.Activities)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - Activities query failed", "error", resp.Error)
|
||||
}
|
||||
// Watched shows count.
|
||||
resp = db.
|
||||
Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "tv").
|
||||
Find(&entity.Watched{}).
|
||||
Count(&stats.WatchedShows)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedShows query failed", "error", resp.Error)
|
||||
}
|
||||
// Watched movies count.
|
||||
resp = db.
|
||||
Joins("JOIN contents ON contents.id = watcheds.content_id AND contents.type = ?", "movie").
|
||||
Find(&entity.Watched{}).
|
||||
Count(&stats.WatchedMovies)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - WatchedMovies query failed", "error", resp.Error)
|
||||
}
|
||||
// Most watched show.
|
||||
var w entity.Watched
|
||||
resp = db.
|
||||
Model(&entity.Watched{}).
|
||||
Select("content_id, COUNT(*) AS mag").
|
||||
Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "tv").
|
||||
Group("content_id").
|
||||
Order("mag DESC").
|
||||
Preload("Content").
|
||||
First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedShow query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedShow = *w.Content
|
||||
}
|
||||
// Most watched movie.
|
||||
resp = db.
|
||||
Model(&entity.Watched{}).
|
||||
Select("content_id, COUNT(*) AS mag").
|
||||
Joins("JOIN contents ON contents.type = ? AND contents.id = watcheds.content_id", "movie").
|
||||
Group("content_id").
|
||||
Order("mag DESC").
|
||||
Preload("Content").
|
||||
First(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("getServerStats - MostWatchedMovie query failed", "error", resp.Error)
|
||||
} else {
|
||||
stats.MostWatchedMovie = *w.Content
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package setup
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth"
|
||||
"github.com/sbondCo/Watcharr/feature/setup/setupglob"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthProvider interface {
|
||||
RegisterFirstUser(urr *auth.UserRegisterRequest, db *gorm.DB) (auth.AuthResponse, error)
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
authProvider AuthProvider
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
// Since we cannot remove these setup routes after they are registered,
|
||||
// each route/service should ensure we are still in setup before continuing.
|
||||
// After server restart, these routes shouldn't exist if setup finished
|
||||
// (currently it is finished if a user is created).
|
||||
//
|
||||
// Each controller can check ServerInSetup var first, then each service
|
||||
// can double check what it needs to (eg create_admin service, registerFirstUser,
|
||||
// will check that no users exist).
|
||||
func (r *Router) AddRoutes() {
|
||||
setup := r.br.Router.Group("/setup")
|
||||
|
||||
// Server setup routes are being added, so we are in setup now.
|
||||
setupglob.ServerInSetup = true
|
||||
|
||||
setup.POST("/create_admin", r.CreateAdmin)
|
||||
}
|
||||
|
||||
// Create first user (which will be an admin).
|
||||
func (r *Router) CreateAdmin(c *gin.Context) {
|
||||
if !setupglob.ServerInSetup {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: "not in setup"})
|
||||
return
|
||||
}
|
||||
var user auth.UserRegisterRequest
|
||||
if c.ShouldBindJSON(&user) == nil {
|
||||
response, err := r.authProvider.RegisterFirstUser(&user, r.br.DB)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
} else {
|
||||
// Set in setup to false after first user registered successfully
|
||||
setupglob.ServerInSetup = false
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.Status(400)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package setupglob
|
||||
|
||||
var ServerInSetup = false
|
||||
@@ -0,0 +1,113 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
tag := r.br.Router.Group("/tag").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
tag.GET("", r.GetTags)
|
||||
// TODO implement getting a tag (with pagination support)
|
||||
// tag.GET(":id", r.GetTag)
|
||||
tag.POST("", r.CreateTag)
|
||||
tag.PUT(":id", r.UpdateTag)
|
||||
tag.DELETE(":id", r.DeleteTag)
|
||||
}
|
||||
|
||||
// Get all of our tags.
|
||||
func (r *Router) GetTags(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
tags, err := GetTags(r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// Get all items within one of our tags.
|
||||
func (r *Router) GetTag(c *gin.Context) {
|
||||
// id, err := strconv.Atoi(c.Param("id"))
|
||||
// if err != nil {
|
||||
// slog.Error("getTag route failed to convert id param to int", "error", err)
|
||||
// c.Status(http.StatusBadRequest)
|
||||
// return
|
||||
// }
|
||||
// userId := c.MustGet("userId").(uint)
|
||||
// tags, err := getTag(r.br.DB, userId, uint(id))
|
||||
// if err != nil {
|
||||
// c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
// return
|
||||
// }
|
||||
// c.JSON(http.StatusOK, tags)
|
||||
}
|
||||
|
||||
// Create a tag.
|
||||
func (r *Router) CreateTag(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var tr TagAddRequest
|
||||
err := c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
response, err := AddTag(r.br.DB, userId, tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) UpdateTag(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag update rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
var tr TagAddRequest
|
||||
err = c.ShouldBindJSON(&tr)
|
||||
if err == nil {
|
||||
err := UpdateTag(r.br.DB, userId, uint(id), tr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
func (r *Router) DeleteTag(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
slog.Error("tag delete rote: failed to process tag id.", "error", err.Error(), "id", c.Param("id"))
|
||||
return
|
||||
}
|
||||
err = DeleteTag(r.br.DB, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package tag
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/dbmodel"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// I think tags will be private for the user.
|
||||
// If the user wants to make a public list, they should make a custom view.
|
||||
|
||||
type TagAddRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
BgColor string `json:"bgColor"`
|
||||
}
|
||||
|
||||
func GetTags(db *gorm.DB, userId uint) ([]entity.Tag, error) {
|
||||
tags := new([]entity.Tag)
|
||||
res := db.Model(&entity.Tag{}).Where("user_id = ?", userId).Find(&tags)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTags: Failed getting tags from database", "error", res.Error.Error())
|
||||
return []entity.Tag{}, errors.New("failed getting tags")
|
||||
}
|
||||
return *tags, nil
|
||||
}
|
||||
|
||||
// func GetTag(db *gorm.DB, userId uint, tagId uint) (Tag, error) {
|
||||
// tag := new(Tag)
|
||||
// res := db.Model(&Tag{}).Where("id = ? AND user_id = ?", tagId, userId).Preload("Watched").Find(&tag)
|
||||
// if res.Error != nil {
|
||||
// slog.Error("getTag: Failed getting tag from database", "error", res.Error.Error())
|
||||
// return Tag{}, errors.New("failed getting tag")
|
||||
// }
|
||||
// if tag.ID == 0 {
|
||||
// slog.Error("getTag: Tag does not exist for this user.", "user_id", userId)
|
||||
// return Tag{}, errors.New("tag does not exist")
|
||||
// }
|
||||
// return *tag, nil
|
||||
// }
|
||||
|
||||
// This method should only be used when we don't have the tagId
|
||||
// (eg: when we are importing data) because this is not technically
|
||||
// reliable, since users can have multiple tags with the same name/colors
|
||||
// (realistically they probably won't, but...).
|
||||
func GetTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (entity.Tag, error) {
|
||||
tag := new(entity.Tag)
|
||||
res := db.Model(&entity.Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
|
||||
return entity.Tag{}, errors.New("failed getting tag")
|
||||
}
|
||||
if tag.ID == 0 {
|
||||
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
|
||||
return entity.Tag{}, errors.New("tag does not exist")
|
||||
}
|
||||
return *tag, nil
|
||||
}
|
||||
|
||||
// Let user create a tag.
|
||||
func AddTag(db *gorm.DB, userId uint, tr TagAddRequest) (entity.Tag, error) {
|
||||
if tr.Name == "" {
|
||||
return entity.Tag{}, errors.New("tag must have a name")
|
||||
}
|
||||
tag := entity.Tag{UserID: userId, Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Create(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding tag to database", "error", res.Error.Error())
|
||||
return entity.Tag{}, errors.New("failed adding new tag to database")
|
||||
}
|
||||
slog.Debug("Adding tag", "added_tag", tag)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Let user update one of their tags (replaces).
|
||||
func UpdateTag(db *gorm.DB, userId uint, tagId uint, tr TagAddRequest) error {
|
||||
if tr.Name == "" {
|
||||
return errors.New("tag must have a name")
|
||||
}
|
||||
tag := entity.Tag{Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Where("id = ? AND user_id = ?", tagId, userId).Updates(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating tag in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating tag in database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("updateTag: Zero rows affected.. tag likely does not exist", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
slog.Debug("updateTag:", "updated_tag", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Let user delete their own tag.
|
||||
func DeleteTag(db *gorm.DB, userId uint, tagId uint) error {
|
||||
if tagId == 0 {
|
||||
return errors.New("no tag id provided")
|
||||
}
|
||||
slog.Debug("deleteTag:", "tag_id", tagId, "user_id", userId)
|
||||
// Select("Watched") so relations in watched_tags table are removed too.
|
||||
// ID is passed in the .Delete param so the .Select call can do it's job (relies on the primary key).
|
||||
res := db.Unscoped().Where("id = ? AND user_id = ?", tagId, userId).Select("Watched").Delete(&entity.Tag{GormModel: dbmodel.GormModel{ID: tagId}})
|
||||
if res.Error != nil {
|
||||
slog.Error("deleteTag: Error deleting tag from database", "error", res.Error.Error(), "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("failed deleting tag from database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("deleteTag: Zero rows affected.. tag must not exist for user", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
task := r.br.Router.Group("/task").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg), authmiddleware.AdminRequired())
|
||||
|
||||
task.GET("/", r.GetAllTasks)
|
||||
task.PUT(":name", r.UpdateTaskSchedule)
|
||||
}
|
||||
|
||||
func (r *Router) GetAllTasks(c *gin.Context) {
|
||||
response := getAllTasks(r.br.Cfg)
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) UpdateTaskSchedule(c *gin.Context) {
|
||||
if c.Param("name") == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "no task name provided"})
|
||||
return
|
||||
}
|
||||
var rr TaskRescheduleRequest
|
||||
err := c.ShouldBindJSON(&rr)
|
||||
if err == nil {
|
||||
err := rescheduleTask(r.br.Cfg, c.Param("name"), rr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package task
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/feature/arr"
|
||||
"github.com/sbondCo/Watcharr/feature/image"
|
||||
"github.com/sbondCo/Watcharr/token"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -42,7 +46,7 @@ var taskScheduler gocron.Scheduler
|
||||
var taskFuncs map[string]TaskFunc
|
||||
|
||||
// Setup recurring tasks (eg cleanup every x mins)
|
||||
func setupTasks(db *gorm.DB) {
|
||||
func SetupTasks(cfg *config.ServerConfig, db *gorm.DB) {
|
||||
ts, err := gocron.NewScheduler()
|
||||
if err != nil {
|
||||
slog.Error("SetupTasks: Failed to create new scheduler!", "error", err)
|
||||
@@ -54,19 +58,19 @@ func setupTasks(db *gorm.DB) {
|
||||
taskFuncs = map[string]TaskFunc{
|
||||
"Cleanup Tokens": {
|
||||
f: func() {
|
||||
cleanupTokens(db)
|
||||
token.CleanupTokens(db)
|
||||
},
|
||||
dd: 60 * time.Second,
|
||||
},
|
||||
"Refresh Arr Queues": {
|
||||
f: func() {
|
||||
refreshArrQueues()
|
||||
arr.RefreshArrQueues(cfg)
|
||||
},
|
||||
dd: 60 * time.Second,
|
||||
},
|
||||
"Cleanup Images": {
|
||||
f: func() {
|
||||
cleanupImages(db)
|
||||
image.CleanupImages(db)
|
||||
},
|
||||
dd: 24 * time.Hour,
|
||||
},
|
||||
@@ -74,7 +78,7 @@ func setupTasks(db *gorm.DB) {
|
||||
|
||||
// Add all jobs to scheduler.
|
||||
for k, v := range taskFuncs {
|
||||
err = addTaskToScheduler(k, v.dd)
|
||||
err = addTaskToScheduler(cfg, k, v.dd)
|
||||
if err != nil {
|
||||
slog.Error("SetupTasks: Failed to add new job", "job", k, "err", err)
|
||||
}
|
||||
@@ -85,17 +89,17 @@ func setupTasks(db *gorm.DB) {
|
||||
}
|
||||
|
||||
// Gets schedule from config, or `defaultDur` if not manually configured.
|
||||
func getTaskSeconds(name string, defaultDur time.Duration) time.Duration {
|
||||
func getTaskSeconds(cfg *config.ServerConfig, name string, defaultDur time.Duration) time.Duration {
|
||||
s := defaultDur
|
||||
if Config.TASK_SCHEDULE[name] != 0 {
|
||||
s = time.Duration(Config.TASK_SCHEDULE[name]) * time.Second
|
||||
if cfg.TASK_SCHEDULE[name] != 0 {
|
||||
s = time.Duration(cfg.TASK_SCHEDULE[name]) * time.Second
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Add new job to scheduler.
|
||||
func addTaskToScheduler(name string, defaultDur time.Duration) error {
|
||||
s := getTaskSeconds(name, defaultDur)
|
||||
func addTaskToScheduler(cfg *config.ServerConfig, name string, defaultDur time.Duration) error {
|
||||
s := getTaskSeconds(cfg, name, defaultDur)
|
||||
_, err := taskScheduler.NewJob(
|
||||
gocron.DurationJob(s),
|
||||
gocron.NewTask(taskFuncs[name].f),
|
||||
@@ -106,7 +110,7 @@ func addTaskToScheduler(name string, defaultDur time.Duration) error {
|
||||
}
|
||||
|
||||
// Get all tasks in a consumable format.
|
||||
func getAllTasks() []AllTasksResponse {
|
||||
func getAllTasks(cfg *config.ServerConfig) []AllTasksResponse {
|
||||
jobs := []AllTasksResponse{}
|
||||
for _, j := range taskScheduler.Jobs() {
|
||||
j2a := AllTasksResponse{
|
||||
@@ -118,7 +122,7 @@ func getAllTasks() []AllTasksResponse {
|
||||
} else {
|
||||
j2a.NextRun = nextRun
|
||||
}
|
||||
j2a.Seconds = int(getTaskSeconds(j2a.Name, taskFuncs[j2a.Name].dd).Seconds())
|
||||
j2a.Seconds = int(getTaskSeconds(cfg, j2a.Name, taskFuncs[j2a.Name].dd).Seconds())
|
||||
jobs = append(jobs, j2a)
|
||||
}
|
||||
return jobs
|
||||
@@ -137,7 +141,7 @@ func getTask(name string) *gocron.Job {
|
||||
}
|
||||
|
||||
// Reschedule a task by name.
|
||||
func rescheduleTask(name string, req TaskRescheduleRequest) error {
|
||||
func rescheduleTask(cfg *config.ServerConfig, name string, req TaskRescheduleRequest) error {
|
||||
if req.Seconds == 0 {
|
||||
return errors.New("request has no seconds")
|
||||
}
|
||||
@@ -146,11 +150,11 @@ func rescheduleTask(name string, req TaskRescheduleRequest) error {
|
||||
return errors.New("no task found")
|
||||
}
|
||||
// Update config
|
||||
if Config.TASK_SCHEDULE == nil {
|
||||
Config.TASK_SCHEDULE = map[string]int{}
|
||||
if cfg.TASK_SCHEDULE == nil {
|
||||
cfg.TASK_SCHEDULE = map[string]int{}
|
||||
}
|
||||
Config.TASK_SCHEDULE[name] = req.Seconds
|
||||
if err := writeConfig(); err != nil {
|
||||
cfg.TASK_SCHEDULE[name] = req.Seconds
|
||||
if err := cfg.Write(); err != nil {
|
||||
slog.Error("rescheduleTask: Failed to write updated config to file!", "error", err)
|
||||
return errors.New("failed to write config")
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
}
|
||||
|
||||
func NewRouter(br *router.BaseRouter) *Router {
|
||||
return &Router{br: br}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
u := r.br.Router.Group("/user").Use(authmiddleware.AuthRequired(r.br.DB, r.br.Cfg))
|
||||
|
||||
// Get current user info
|
||||
u.GET("", r.GetUserInfo)
|
||||
// Update current user settings
|
||||
u.POST("/update", r.UpdateSettings)
|
||||
// Get current user setting
|
||||
u.GET("/settings", r.GetSettings)
|
||||
// Search users
|
||||
u.GET("/search", r.GetSearchUsers)
|
||||
// Get user public info
|
||||
u.GET("/public/:pubUserId/:pubUsername", r.GetUserPublicInfo)
|
||||
// Update bio
|
||||
u.POST("/bio", r.UpdateBio)
|
||||
// Upload avatar
|
||||
u.POST("/avatar", r.UpdateAvatar)
|
||||
}
|
||||
|
||||
// Get current user info
|
||||
func (r *Router) GetUserInfo(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := getUserInfo(r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Update current user settings
|
||||
func (r *Router) UpdateSettings(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ur entity.UserSettings
|
||||
err := c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
response, err := userUpdate(r.br.DB, userId, ur)
|
||||
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()})
|
||||
}
|
||||
|
||||
// Get current user setting
|
||||
func (r *Router) GetSettings(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := UserGetSettings(r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Search users
|
||||
func (r *Router) GetSearchUsers(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
query := c.Query("q")
|
||||
if query == "" {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "a query was not provided"})
|
||||
return
|
||||
}
|
||||
response, err := userSearch(r.br.DB, userId, query)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Get user public info
|
||||
func (r *Router) GetUserPublicInfo(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("pubUserId"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := getUserPublicInfo(r.br.DB, uint(id), c.Param("pubUsername"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// Update bio
|
||||
func (r *Router) UpdateBio(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var br UserBioUpdateRequest
|
||||
err := c.ShouldBindJSON(&br)
|
||||
if err == nil {
|
||||
err := userUpdateBio(r.br.DB, userId, br.NewBio)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
return
|
||||
}
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
}
|
||||
|
||||
// Upload avatar
|
||||
func (r *Router) UpdateAvatar(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := uploadUserAvatar(c, r.br.DB, userId)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package user
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
@@ -11,40 +11,24 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/image"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Public user details for search results
|
||||
type PublicUser struct {
|
||||
ID uint `json:"id"`
|
||||
Username string `json:"username"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio,omitempty"`
|
||||
}
|
||||
|
||||
// Private user details, for returning users details to themselves
|
||||
type PrivateUser struct {
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
AvatarID uint `json:"-"`
|
||||
Avatar Image `json:"avatar"`
|
||||
Bio string `json:"bio"`
|
||||
}
|
||||
|
||||
type UserBioUpdateRequest struct {
|
||||
NewBio string `json:"newBio" binding:"max=128"`
|
||||
}
|
||||
|
||||
// Update user settings
|
||||
func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error) {
|
||||
func userUpdate(db *gorm.DB, userId uint, ur entity.UserSettings) (entity.UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId, "ur", ur)
|
||||
user := new(User)
|
||||
user := new(entity.User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user update failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
return entity.UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
if ur.HideSpoilers != nil {
|
||||
user.HideSpoilers = ur.HideSpoilers
|
||||
@@ -71,7 +55,7 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
user.RatingStep = ur.RatingStep
|
||||
}
|
||||
db.Save(&user)
|
||||
return UserSettings{
|
||||
return entity.UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
@@ -81,15 +65,15 @@ func userUpdate(db *gorm.DB, userId uint, ur UserSettings) (UserSettings, error)
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
func UserGetSettings(db *gorm.DB, userId uint) (entity.UserSettings, error) {
|
||||
slog.Debug("user update request running", "user_id", userId)
|
||||
user := new(User)
|
||||
user := new(entity.User)
|
||||
res := db.Where("id = ?", userId).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get failed", "user_id", userId, "error", res.Error)
|
||||
return UserSettings{}, errors.New("failed to retrieve user")
|
||||
return entity.UserSettings{}, errors.New("failed to retrieve user")
|
||||
}
|
||||
return UserSettings{
|
||||
return entity.UserSettings{
|
||||
Private: user.Private,
|
||||
PrivateThoughts: user.PrivateThoughts,
|
||||
HideSpoilers: user.HideSpoilers,
|
||||
@@ -101,61 +85,61 @@ func userGetSettings(db *gorm.DB, userId uint) (UserSettings, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]PublicUser, error) {
|
||||
func userSearch(db *gorm.DB, currentUsersId uint, q string) ([]entity.PublicUser, error) {
|
||||
slog.Debug("user search request running", "query", q)
|
||||
users := new([]PublicUser)
|
||||
users := new([]entity.PublicUser)
|
||||
res := db.Where("private = 0 AND username LIKE ? AND id != ?", "%"+q+"%", currentUsersId).Table("users").Find(&users)
|
||||
if res.Error != nil {
|
||||
slog.Error("user search failed", "error", res.Error)
|
||||
return []PublicUser{}, errors.New("failed to find users")
|
||||
return []entity.PublicUser{}, errors.New("failed to find users")
|
||||
}
|
||||
return *users, nil
|
||||
}
|
||||
|
||||
func getUserInfo(db *gorm.DB, currentUsersId uint) (PrivateUser, error) {
|
||||
func getUserInfo(db *gorm.DB, currentUsersId uint) (entity.PrivateUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PrivateUser)
|
||||
user := new(entity.PrivateUser)
|
||||
res := db.Where("id = ?", currentUsersId).Table("users").Preload("Avatar").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("user get info failed", "error", res.Error)
|
||||
return PrivateUser{}, errors.New("failed to find current user")
|
||||
return entity.PrivateUser{}, errors.New("failed to find current user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
// For getting a public user's info, when viewing their list for example
|
||||
func getUserPublicInfo(db *gorm.DB, userId uint, username string) (PublicUser, error) {
|
||||
func getUserPublicInfo(db *gorm.DB, userId uint, username string) (entity.PublicUser, error) {
|
||||
slog.Debug("user get info request running")
|
||||
user := new(PublicUser)
|
||||
user := new(entity.PublicUser)
|
||||
res := db.Where("private = 0 AND id = ? AND username = ?", userId, username).Table("users").Preload("Avatar").Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("public user get info failed", "error", res.Error)
|
||||
return PublicUser{}, errors.New("failed to find user")
|
||||
return entity.PublicUser{}, errors.New("failed to find user")
|
||||
}
|
||||
return *user, nil
|
||||
}
|
||||
|
||||
func userUpdateBio(db *gorm.DB, userId uint, newBio string) error {
|
||||
slog.Debug("userUpdateBio request running", "user_id", userId, "newBio", newBio)
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Update("bio", newBio); res.Error != nil {
|
||||
if res := db.Model(&entity.User{}).Where("id = ?", userId).Update("bio", newBio); res.Error != nil {
|
||||
slog.Error("userUpdateBio failed", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update bio")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (entity.Image, error) {
|
||||
file, err := c.FormFile("avatar")
|
||||
if err != nil {
|
||||
slog.Error("failed to get file", "error", err)
|
||||
return Image{}, errors.New("no file found")
|
||||
return entity.Image{}, errors.New("no file found")
|
||||
}
|
||||
|
||||
slog.Debug("an avatar is being uploaded", "name", file.Filename)
|
||||
|
||||
f, _ := file.Open()
|
||||
if err := isValidImageType(f); err != nil {
|
||||
return Image{}, errors.New("invalid image type")
|
||||
if err := image.IsValidImageType(f); err != nil {
|
||||
return entity.Image{}, errors.New("invalid image type")
|
||||
}
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
@@ -167,7 +151,7 @@ func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
|
||||
// Upload the file to specific dst.
|
||||
outp := path.Join("img/up/", hs[0:1], hs+filepath.Ext(file.Filename))
|
||||
c.SaveUploadedFile(file, path.Join(DataPath, outp))
|
||||
c.SaveUploadedFile(file, path.Join(config.DataPath, outp))
|
||||
|
||||
_, err = f.Seek(0, 0)
|
||||
if err != nil {
|
||||
@@ -175,10 +159,10 @@ func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
}
|
||||
|
||||
// No need to remove old image, the daily cleanup task will handle removing unused ones.
|
||||
var img Image
|
||||
var img entity.Image
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// Insert avatar into db
|
||||
img, err = insertImage(db, hs, outp, f)
|
||||
img, err = image.InsertImage(db, hs, outp, f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -186,7 +170,7 @@ func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
return errors.New("image has no id")
|
||||
}
|
||||
// Update users avatar to newly inserted
|
||||
if err := tx.Where("id = ?", userId).Updates(&User{AvatarID: img.ID}).Error; err != nil {
|
||||
if err := tx.Where("id = ?", userId).Updates(&entity.User{AvatarID: img.ID}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// commit transaction if no errors
|
||||
@@ -194,7 +178,7 @@ func uploadUserAvatar(c *gin.Context, db *gorm.DB, userId uint) (Image, error) {
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("uploadUserAvatar failed!", "error", err)
|
||||
return Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
return entity.Image{}, errors.New("uploadUserAvatar transaction failed")
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
@@ -1,42 +1,43 @@
|
||||
package main
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// User details wanted for management views.
|
||||
type ManagedUser struct {
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
Type UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
Private bool `json:"private"`
|
||||
ID uint `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Username string `json:"username"`
|
||||
Type entity.UserType `json:"type"`
|
||||
Permissions int `json:"permissions"`
|
||||
Private bool `json:"private"`
|
||||
}
|
||||
|
||||
type UpdateUserRequest struct {
|
||||
Permissions *int `json:"permissions"`
|
||||
Type *UserType `json:"type"`
|
||||
Permissions *int `json:"permissions"`
|
||||
Type *entity.UserType `json:"type"`
|
||||
}
|
||||
|
||||
func getAllUsers(db *gorm.DB) ([]ManagedUser, error) {
|
||||
func GetAll(db *gorm.DB) ([]ManagedUser, error) {
|
||||
users := []ManagedUser{}
|
||||
if res := db.Model(&User{}).Find(&users); res.Error != nil {
|
||||
slog.Error("getAllUsers: Failed to fetch users from database", "error", res.Error)
|
||||
if res := db.Model(&entity.User{}).Find(&users); res.Error != nil {
|
||||
slog.Error("GetAllUsers: Failed to fetch users from database", "error", res.Error)
|
||||
return []ManagedUser{}, errors.New("failed to fetch users from database")
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
// Update a user. For management views, for admin to update another user.
|
||||
func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
func Manage(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
// Error now if no userId or any UpdateUserRequest property was provided.
|
||||
if userId == 0 || (ur.Permissions == nil && ur.Type == nil) {
|
||||
slog.Error("manageUser: invalid arguments", "user_id", userId)
|
||||
slog.Error("ManageUser: invalid arguments", "user_id", userId)
|
||||
return errors.New("invalid arguments, ensure a valid userId and at least one property has been provided for updating")
|
||||
}
|
||||
toUpdate := map[string]interface{}{}
|
||||
@@ -45,25 +46,25 @@ func manageUser(db *gorm.DB, userId uint, ur UpdateUserRequest) error {
|
||||
// If removing all perms, set to default of 1 (PERM_NONE).
|
||||
// Will avoid confusion and possibly bugs later on, though I doubt
|
||||
// we'd ever be (directly) checking a user to ensure they have no perms.
|
||||
toUpdate["permissions"] = PERM_NONE
|
||||
toUpdate["permissions"] = entity.PERM_NONE
|
||||
} else {
|
||||
toUpdate["permissions"] = *ur.Permissions
|
||||
}
|
||||
}
|
||||
if ur.Type != nil {
|
||||
t := *ur.Type
|
||||
if t == WATCHARR_USER || t == PROXY_USER {
|
||||
if t == entity.WATCHARR_USER || t == entity.PROXY_USER {
|
||||
// Currently only swapping between watcharr/proxy user is supported.
|
||||
slog.Debug("manageUser: User type is being updated.", "new_type", t)
|
||||
slog.Debug("ManageUser: User type is being updated.", "new_type", t)
|
||||
toUpdate["type"] = t
|
||||
} else {
|
||||
slog.Warn("manageUser: User type will not be updated. Only watcharr/proxy types are supported for swapping.", "tried_type", t)
|
||||
slog.Warn("ManageUser: User type will not be updated. Only watcharr/proxy types are supported for swapping.", "tried_type", t)
|
||||
}
|
||||
}
|
||||
if res := db.Model(&User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
|
||||
slog.Error("manageUser: failed to update user in database", "user_id", userId, "error", res.Error)
|
||||
if res := db.Model(&entity.User{}).Where("id = ?", userId).Updates(toUpdate); res.Error != nil {
|
||||
slog.Error("ManageUser: failed to update user in database", "user_id", userId, "error", res.Error)
|
||||
return errors.New("failed to update user in database")
|
||||
}
|
||||
slog.Debug("manageUser: A user has been updated", "user_id", userId)
|
||||
slog.Debug("ManageUser: A user has been updated", "user_id", userId)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package addedtocontent
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This struct is for embedding inside content response structs.
|
||||
// This holds the watched entry response data that will go along
|
||||
// with the content responses.
|
||||
type WatchedAddedToContent struct {
|
||||
// The related watched entry.
|
||||
Watched *entity.Watched `json:"watched,omitempty"`
|
||||
// If we failed to get the watched entry,
|
||||
// set this to true, so the frontend can
|
||||
// notify the user of why there is possibly
|
||||
// missing watched list data.
|
||||
FailedToGetWatched bool `json:"failedToGetWatched,omitempty"`
|
||||
}
|
||||
|
||||
type Addable interface {
|
||||
AddWatched(w *entity.Watched)
|
||||
GetId() int
|
||||
GetMediaType() string
|
||||
}
|
||||
|
||||
type WatchedProvider interface {
|
||||
GetWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]entity.Watched, error)
|
||||
}
|
||||
|
||||
func AddWAC[S Addable](s []S, wp WatchedProvider, db *gorm.DB, userId uint) error {
|
||||
contentIdAndTypePairs := [][]any{}
|
||||
for _, v := range s {
|
||||
contentIdAndTypePairs = append(contentIdAndTypePairs, []any{
|
||||
v.GetId(),
|
||||
entity.ContentType(v.GetMediaType()),
|
||||
})
|
||||
}
|
||||
if ws, err := wp.GetWatchedItemsByTmdbIds(db, userId, contentIdAndTypePairs); err == nil {
|
||||
for _, v := range ws {
|
||||
for _, vv := range s {
|
||||
if vv.GetId() == v.Content.TmdbID && vv.GetMediaType() == string(v.Content.Type) {
|
||||
vv.AddWatched(&v)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by tmdbIds failed!")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package episode
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"github.com/sbondCo/Watcharr/feature/user"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/season"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type WatchedEpisodeAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
AddActivity entity.ActivityType `json:"-"`
|
||||
AddActivityDate time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedEpisodeAddResponse struct {
|
||||
WatchedEpisodes []entity.WatchedEpisode `json:"watchedEpisodes"`
|
||||
AddedActivity entity.Activity `json:"addedActivity"`
|
||||
// Response from hook
|
||||
EpisodeStatusChangedHookResponse EpisodeStatusChangedHookResponse `json:"episodeStatusChangedHookResponse,omitempty"`
|
||||
}
|
||||
|
||||
type EpisodeStatusChangedHookResponse struct {
|
||||
// The watched shows status if we modified it.
|
||||
NewShowStatus entity.WatchedStatus `json:"newShowStatus,omitempty"`
|
||||
// The full watched season (if created or modified).
|
||||
WatchedSeason *entity.WatchedSeason `json:"watchedSeason,omitempty"`
|
||||
// All activies we have added.
|
||||
AddedActivities []entity.Activity `json:"addedActivities,omitempty"`
|
||||
// All errors (fatal and non-fatal) that were encountered.
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedProvider interface {
|
||||
GetWatchedItemById(db *gorm.DB, userId uint, id uint) (entity.Watched, error)
|
||||
}
|
||||
|
||||
type WatchedSeasonProvider interface {
|
||||
GetWatchedSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int) (*entity.WatchedSeason, error)
|
||||
AddWatchedSeason(db *gorm.DB, userId uint, ar season.WatchedSeasonAddRequest) (season.WatchedSeasonAddResponse, error)
|
||||
}
|
||||
|
||||
type ContentProvider interface {
|
||||
SeasonDetails(tvId string, seasonNumber string) (tmdb.TMDBSeasonDetails, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
wp WatchedProvider
|
||||
wsp WatchedSeasonProvider
|
||||
cp ContentProvider
|
||||
}
|
||||
|
||||
func NewService(wp WatchedProvider, wsp WatchedSeasonProvider, cp ContentProvider) *Service {
|
||||
return &Service{
|
||||
wp,
|
||||
wsp,
|
||||
cp,
|
||||
}
|
||||
}
|
||||
|
||||
// Add/edit a watched episode.
|
||||
func (s *Service) AddWatchedEpisodes(db *gorm.DB, userId uint, ar WatchedEpisodeAddRequest) (WatchedEpisodeAddResponse, error) {
|
||||
slog.Debug("Adding watched episode item", "userId", userId, "watchedID", ar.WatchedID, "season", ar.SeasonNumber, "episode", ar.EpisodeNumber)
|
||||
// 1. Make sure watched item exists and it is the correct type (TV)
|
||||
var w entity.Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", ar.WatchedID, userId).Preload("Content").Preload("WatchedEpisodes").Find(&w); resp.Error != nil {
|
||||
slog.Error("Failed when adding a watched episode", "error", "failed to get watched item from db")
|
||||
return WatchedEpisodeAddResponse{}, errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("Failed when adding a watched episode", "error", "watched item does not exist in db")
|
||||
return WatchedEpisodeAddResponse{}, errors.New("can't add a watched episode for a show that doesnt have a status itself")
|
||||
}
|
||||
if w.Content.Type != entity.SHOW {
|
||||
return WatchedEpisodeAddResponse{}, errors.New("can't add watched episode for non show content")
|
||||
}
|
||||
found := false
|
||||
updated := false
|
||||
for i, we := range w.WatchedEpisodes {
|
||||
if we.SeasonNumber == ar.SeasonNumber && we.EpisodeNumber == ar.EpisodeNumber {
|
||||
slog.Debug("Existing watched episode item found, updating existing")
|
||||
found = true
|
||||
if ar.Status != "" && ar.Status != w.WatchedEpisodes[i].Status {
|
||||
w.WatchedEpisodes[i].Status = ar.Status
|
||||
updated = true
|
||||
}
|
||||
if ar.Rating != 0 && ar.Rating != w.WatchedEpisodes[i].Rating {
|
||||
w.WatchedEpisodes[i].Rating = ar.Rating
|
||||
updated = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
var addedActivity entity.Activity
|
||||
if !found {
|
||||
slog.Debug("Existing watched episode not found, adding as new entry")
|
||||
w.WatchedEpisodes = append(w.WatchedEpisodes, entity.WatchedEpisode{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
SeasonNumber: ar.SeasonNumber,
|
||||
EpisodeNumber: ar.EpisodeNumber,
|
||||
Status: ar.Status,
|
||||
Rating: ar.Rating,
|
||||
})
|
||||
}
|
||||
if resp := db.Save(&w.WatchedEpisodes); resp.Error != nil {
|
||||
slog.Debug("Failed to save watched episode item in db", "error", resp.Error)
|
||||
return WatchedEpisodeAddResponse{}, errors.New("failed to save")
|
||||
}
|
||||
// Add activity
|
||||
if found {
|
||||
// Only add change activity if we actually updated a value
|
||||
// (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, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.EPISODE_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "episode": ar.EpisodeNumber, "status": ar.Status, "rating": ar.Rating})
|
||||
act := activity.ActivityAddRequest{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, _ = activity.AddActivity(db, userId, act)
|
||||
}
|
||||
episodeAddResp := WatchedEpisodeAddResponse{
|
||||
WatchedEpisodes: w.WatchedEpisodes,
|
||||
AddedActivity: addedActivity,
|
||||
}
|
||||
if ar.Status != "" {
|
||||
slog.Debug("addWatchedEpisodes: Episode status was changed, calling hook.")
|
||||
episodeAddResp.EpisodeStatusChangedHookResponse = s.hookEpisodeStatusChanged(db, userId, ar.WatchedID, ar.SeasonNumber, ar.EpisodeNumber, ar.Status)
|
||||
}
|
||||
return episodeAddResp, nil
|
||||
}
|
||||
|
||||
// Remove a watched episode
|
||||
func (s *Service) rmWatchedEpisode(db *gorm.DB, userId uint, id uint) (entity.Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "id", id)
|
||||
var watchedEpisode entity.WatchedEpisode
|
||||
resp := 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")
|
||||
}
|
||||
if resp.RowsAffected == 0 {
|
||||
slog.Error("Failed when removing a watched episode", "error", "zero rows affected")
|
||||
return entity.Activity{}, errors.New("wasn't removed from db.. may not exist")
|
||||
}
|
||||
slog.Debug("rmWatchedEpisode, deleted row", "row", watchedEpisode)
|
||||
if watchedEpisode.ID != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{
|
||||
"season": watchedEpisode.SeasonNumber,
|
||||
"episode": watchedEpisode.EpisodeNumber,
|
||||
"status": watchedEpisode.Status,
|
||||
"rating": watchedEpisode.Rating,
|
||||
})
|
||||
addedActivity, _ := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watchedEpisode.WatchedID, Type: entity.EPISODE_REMOVED, Data: string(json)})
|
||||
return addedActivity, nil
|
||||
}
|
||||
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
|
||||
func (s *Service) getNumberOfWatchedEpisodesInSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int, acceptableStatus []entity.WatchedStatus) (int64, error) {
|
||||
var count int64
|
||||
if res := db.Model(&entity.WatchedEpisode{}).Where("user_id = ? AND watched_id = ? AND season_number = ? AND status IN ?", userId, watchedId, seasonNumber, acceptableStatus).Count(&count); res.Error != nil {
|
||||
return 0, res.Error
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// Called after an episode watched status has been set.
|
||||
func (s *Service) hookEpisodeStatusChanged(db *gorm.DB, userId uint, watchedId uint, seasonNum int, episodeNum int, newEpisodeStatus entity.WatchedStatus) EpisodeStatusChangedHookResponse {
|
||||
userSettings, err := user.UserGetSettings(db, userId)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get user settings! Hook will continue.", "error", err)
|
||||
} else {
|
||||
if !*userSettings.AutomateShowStatuses {
|
||||
slog.Debug("hookEpisodeStatusChanged: User has AutomateShowStatuses disabled. Skipping hook.", "user_id", userId)
|
||||
return EpisodeStatusChangedHookResponse{}
|
||||
}
|
||||
}
|
||||
|
||||
hookResponse := EpisodeStatusChangedHookResponse{}
|
||||
|
||||
addHookActivity := func(aType entity.ActivityType, data string) {
|
||||
addedActivity, _ := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watchedId, Type: aType, Data: (data)})
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, addedActivity)
|
||||
}
|
||||
|
||||
// 2. If the season (this episode is in) has no status or is planned, set season to watching.
|
||||
watchedSeason, err := s.wsp.GetWatchedSeason(db, userId, watchedId, seasonNum)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Cannot continue, failed to get watchedSeason!", "error", err)
|
||||
return EpisodeStatusChangedHookResponse{Errors: []string{("failed to query db for watched season")}}
|
||||
}
|
||||
// If season not found, create it.
|
||||
if watchedSeason == nil {
|
||||
slog.Debug("hookEpisodeStatusChanged: Watched season does not exist. Creating now.")
|
||||
seasonStatus := newEpisodeStatus
|
||||
if newEpisodeStatus == entity.FINISHED || newEpisodeStatus == entity.DROPPED {
|
||||
seasonStatus = entity.WATCHING
|
||||
}
|
||||
resp, err := s.wsp.AddWatchedSeason(db, userId, season.WatchedSeasonAddRequest{
|
||||
AddActivity: entity.SEASON_ADDED_AUTO,
|
||||
AddActivityData: map[string]interface{}{"reason": fmt.Sprintf("Episode %d was set to %s while the season had no status.", episodeNum, newEpisodeStatus)},
|
||||
WatchedID: watchedId,
|
||||
SeasonNumber: seasonNum,
|
||||
Status: seasonStatus,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to add watched season!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to add watched season")
|
||||
} else {
|
||||
// addWatchedSeason returns all watched seasons, get the one just added. (may be best to retrofit addWatchedSeason later to return id of season/row created)
|
||||
justAddedWatchedSeason, err := s.wsp.GetWatchedSeason(db, userId, watchedId, seasonNum)
|
||||
if err != nil {
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get newly added watched season for response")
|
||||
} else {
|
||||
watchedSeason = justAddedWatchedSeason
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
}
|
||||
hookResponse.AddedActivities = append(hookResponse.AddedActivities, resp.AddedActivity)
|
||||
}
|
||||
} else if watchedSeason.Status == "" || watchedSeason.Status == entity.PLANNED ||
|
||||
((newEpisodeStatus == entity.FINISHED || newEpisodeStatus == entity.WATCHING) && (watchedSeason.Status == entity.HOLD || watchedSeason.Status == entity.DROPPED)) {
|
||||
reasonStr := fmt.Sprintf("Episode %d was set to %s while the season had ", episodeNum, newEpisodeStatus)
|
||||
if watchedSeason.Status == "" {
|
||||
reasonStr += "no status."
|
||||
} else {
|
||||
reasonStr += fmt.Sprintf("a status of %s.", watchedSeason.Status)
|
||||
}
|
||||
watchedSeason.Status = entity.WATCHING
|
||||
if res := db.Save(watchedSeason); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update season status!", "error", res.Error)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status")
|
||||
} else {
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": watchedSeason.Status, "reason": reasonStr})
|
||||
addHookActivity(entity.SEASON_STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. If the show has no status or is planned, set it to watching.
|
||||
watchedShow, err := s.wp.GetWatchedItemById(db, userId, watchedId)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get watched show, cant continue to update show status.", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get watched item for show")
|
||||
return hookResponse
|
||||
} else {
|
||||
// Show status shouldn't be empty, but watevs, handle it just incase
|
||||
if watchedShow.Status == "" || watchedShow.Status == entity.PLANNED {
|
||||
watchedShow.Status = entity.WATCHING
|
||||
if res := db.Save(watchedShow); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update show status!", "error", res.Error)
|
||||
} else {
|
||||
hookResponse.NewShowStatus = watchedShow.Status
|
||||
json, _ := json.Marshal(map[string]interface{}{"status": watchedShow.Status, "reason": fmt.Sprintf("S%dE%d was set to %s.", seasonNum, episodeNum, newEpisodeStatus)})
|
||||
addHookActivity(entity.STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If all episodes are FINISHED or DROPPED, set the season to FINISHED
|
||||
// BUG If a seasons status is removed and the last episode of the season is marked finished,
|
||||
// this will add activity for the season being marked finished, right after it is set
|
||||
// to Watching just above. I think this might never happen to anyone so um ye.
|
||||
tmdbIdStr := strconv.Itoa(watchedShow.Content.TmdbID)
|
||||
seasonNumStr := strconv.Itoa(seasonNum)
|
||||
seasonDetails, err := s.cp.SeasonDetails(tmdbIdStr, seasonNumStr)
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get season details!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get season details for show")
|
||||
return hookResponse
|
||||
}
|
||||
allEpisodesCount := len(seasonDetails.Episodes)
|
||||
finishedEpisodesCount, err := s.getNumberOfWatchedEpisodesInSeason(db, userId, watchedId, seasonNum, []entity.WatchedStatus{entity.FINISHED, entity.DROPPED})
|
||||
if err != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to get number of watched episodes in this season!", "error", err)
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to get number of watched episodes in this season")
|
||||
return hookResponse
|
||||
}
|
||||
slog.Debug("hookEpisodeStatusChanged: Got episode counts.", "allEpisodesCount", allEpisodesCount, "finishedEpisodesCount", finishedEpisodesCount)
|
||||
if finishedEpisodesCount >= int64(allEpisodesCount) {
|
||||
slog.Debug("hookEpisodeStatusChanged: All episodes have been completed (finished or dropped). Marking season finished.")
|
||||
newStatus := entity.FINISHED
|
||||
if watchedSeason != nil && watchedSeason.Status == newStatus {
|
||||
slog.Debug("hookEpisodeStatusChanged: WatchedSeason status is same as newStatus so not updating.")
|
||||
return hookResponse
|
||||
}
|
||||
if res := db.Model(&entity.WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNum, userId).Update("status", newStatus); res.Error != nil {
|
||||
slog.Error("hookEpisodeStatusChanged: Failed to update season status to finished:", "error", res.Error.Error())
|
||||
hookResponse.Errors = append(hookResponse.Errors, "failed to update season status to finished")
|
||||
return hookResponse
|
||||
} else {
|
||||
if watchedSeason != nil {
|
||||
watchedSeason.Status = newStatus
|
||||
hookResponse.WatchedSeason = watchedSeason
|
||||
} else {
|
||||
slog.Error("hookEpisodeStatusChanged: watchedSeason was nil HOW DID THIS HAPPEN? Anyways the client won't be able to update its state with the new season status until it is refreshed.")
|
||||
}
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": seasonNum, "status": newStatus, "reason": fmt.Sprintf("The season was deemed completed when episode %d was set to %s.", episodeNum, newEpisodeStatus)})
|
||||
addHookActivity(entity.SEASON_STATUS_CHANGED_AUTO, string(json))
|
||||
}
|
||||
}
|
||||
|
||||
return hookResponse
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package episode
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
s *Service
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
br *router.BaseRouter,
|
||||
service *Service,
|
||||
) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
s: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
episode := r.br.Router.Group("/watched/episode").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
episode.POST("", r.AddWatchedEpisode)
|
||||
episode.DELETE(":id", r.DeleteWatchedEpisode)
|
||||
}
|
||||
|
||||
func (r *Router) AddWatchedEpisode(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedEpisodeAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.s.AddWatchedEpisodes(r.br.DB, 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) DeleteWatchedEpisode(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := r.s.rmWatchedEpisode(r.br.DB, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package watched
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
t *tmdb.TMDB
|
||||
s *Service
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
br *router.BaseRouter,
|
||||
t *tmdb.TMDB,
|
||||
service *Service,
|
||||
) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
t: t,
|
||||
s: service,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO all handlers moving here, then the base router will become the initializer of all handlers and
|
||||
// initial creator of all services and passes them down to services that want them
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
watched := r.br.Router.Group("/watched").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
watched.GET("", router.PaginatedRequest(false), r.GetWatchedList)
|
||||
watched.GET(":id/:username", r.GetPublicWatchedList)
|
||||
watched.POST("", r.AddWatched)
|
||||
watched.PUT(":id", r.UpdateWatched)
|
||||
watched.DELETE(":id", r.DeleteWatched)
|
||||
// TODO Move add/delete watched from tag to the `tag` package (the service code is there so the route may as well be under there, also avoids a circular dep).
|
||||
watched.POST(":id/tag/:tagId", r.AddWatchedToTag)
|
||||
watched.DELETE(":id/tag/:tagId", r.DeleteWatchedFromTag)
|
||||
}
|
||||
|
||||
// Get our (logged in user) watched list.
|
||||
func (r *Router) GetWatchedList(c *gin.Context) {
|
||||
isPaginated := c.MustGet("paginationEnabled").(bool)
|
||||
userId := c.MustGet("userId").(uint)
|
||||
if isPaginated {
|
||||
pp := c.MustGet("paginationParams").(util.PaginationParams)
|
||||
wp := WatchedGetPageRequest{
|
||||
// Defaults..
|
||||
Sort: watchedSortDateAdded,
|
||||
SortDir: sortAscending,
|
||||
}
|
||||
if err := c.ShouldBind(&wp); err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "failed to get request parameters"})
|
||||
return
|
||||
}
|
||||
if wp, err := r.s.getWatchedPage(r.br.DB, userId, pp, wp); err == nil {
|
||||
c.JSON(http.StatusOK, wp)
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "failed to get page"})
|
||||
}
|
||||
return
|
||||
}
|
||||
// Non paginated response (doesn't support sorting/filtering atm)
|
||||
if w, err := r.s.getWatched(r.br.DB, userId); err == nil {
|
||||
c.JSON(http.StatusOK, w)
|
||||
} else {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: "failed"})
|
||||
}
|
||||
}
|
||||
|
||||
// Get another users watched list (if its public).
|
||||
func (r *Router) GetPublicWatchedList(c *gin.Context) {
|
||||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
slog.Error("getPublicWatched route failed to convert id param to uint", "id", id)
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
response, err := r.s.getPublicWatched(r.br.DB, uint(id), c.Param("username"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (r *Router) AddWatched(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.s.AddWatched(r.br.DB, userId, ar, entity.ADDED_WATCHED)
|
||||
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) UpdateWatched(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ur WatchedUpdateRequest
|
||||
err = c.ShouldBindJSON(&ur)
|
||||
if err == nil {
|
||||
response, err := r.s.updateWatched(r.br.DB, userId, uint(id), ur)
|
||||
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) DeleteWatched(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err == nil {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := r.s.removeWatched(r.br.DB, userId, uint(id))
|
||||
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) AddWatchedToTag(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = AddWatchedToTag(r.br.DB, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func (r *Router) DeleteWatchedFromTag(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert id param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tagId, err := strconv.Atoi(c.Param("tagId"))
|
||||
if err != nil {
|
||||
slog.Error("tag watched route failed to convert tagId param to int", "error", err)
|
||||
c.Status(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
err = RmWatchedFromTag(r.br.DB, userId, uint(tagId), uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusOK)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package season
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/feature/auth/authmiddleware"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
type Router struct {
|
||||
br *router.BaseRouter
|
||||
s *Service
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
br *router.BaseRouter,
|
||||
service *Service,
|
||||
) *Router {
|
||||
return &Router{
|
||||
br: br,
|
||||
s: service,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) AddRoutes() {
|
||||
season := r.br.Router.Group("/watched/season").Use(authmiddleware.AuthRequired(nil, r.br.Cfg))
|
||||
|
||||
season.POST("/season", r.AddWatchedSeason)
|
||||
season.DELETE("/season/:id", r.DeleteWatchedSeason)
|
||||
}
|
||||
|
||||
func (r *Router) AddWatchedSeason(c *gin.Context) {
|
||||
userId := c.MustGet("userId").(uint)
|
||||
var ar WatchedSeasonAddRequest
|
||||
err := c.ShouldBindJSON(&ar)
|
||||
if err == nil {
|
||||
response, err := r.s.AddWatchedSeason(r.br.DB, 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) DeleteWatchedSeason(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Status(400)
|
||||
return
|
||||
}
|
||||
userId := c.MustGet("userId").(uint)
|
||||
response, err := r.s.RmWatchedSeason(r.br.DB, userId, uint(id))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusForbidden, router.ErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package season
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -6,43 +6,40 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// UniqueIndex applied between WatchedID and SeasonNumber to avoid duplicates incase logic fails.
|
||||
type WatchedSeason struct {
|
||||
GormModel
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
User User `json:"-"`
|
||||
WatchedID uint `json:"-" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
SeasonNumber int `json:"seasonNumber" gorm:"uniqueIndex:ws_watched_to_season_num;not null"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddRequest struct {
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
addActivity ActivityType `json:"-"`
|
||||
addActivityDate time.Time `json:"-"`
|
||||
WatchedID uint `json:"watchedId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating int8 `json:"rating" binding:"max=10"`
|
||||
AddActivity entity.ActivityType `json:"-"`
|
||||
AddActivityDate time.Time `json:"-"`
|
||||
// Data to add to activity if the season is created.
|
||||
// Combined with data we already add.
|
||||
addActivityData map[string]interface{} `json:"-"`
|
||||
AddActivityData map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
type WatchedSeasonAddResponse struct {
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons"`
|
||||
AddedActivity Activity `json:"addedActivity"`
|
||||
WatchedSeasons []entity.WatchedSeason `json:"watchedSeasons"`
|
||||
AddedActivity entity.Activity `json:"addedActivity"`
|
||||
}
|
||||
|
||||
type Service struct{}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
}
|
||||
|
||||
// Add/edit a watched season.
|
||||
func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (WatchedSeasonAddResponse, error) {
|
||||
func (s *Service) AddWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (WatchedSeasonAddResponse, error) {
|
||||
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 Watched
|
||||
var w entity.Watched
|
||||
if resp := 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")
|
||||
@@ -51,7 +48,7 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
slog.Error("Failed when adding a watched season", "error", "watched item does not exist in db")
|
||||
return WatchedSeasonAddResponse{}, errors.New("can't add a watched season for a show that doesnt have a status itself")
|
||||
}
|
||||
if w.Content.Type != SHOW {
|
||||
if w.Content.Type != entity.SHOW {
|
||||
return WatchedSeasonAddResponse{}, errors.New("can't add watched season for non show content")
|
||||
}
|
||||
found := false
|
||||
@@ -71,10 +68,10 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
break
|
||||
}
|
||||
}
|
||||
var addedActivity Activity
|
||||
var addedActivity entity.Activity
|
||||
if !found {
|
||||
slog.Debug("Existing watched season not found, adding as new entry")
|
||||
w.WatchedSeasons = append(w.WatchedSeasons, WatchedSeason{
|
||||
w.WatchedSeasons = append(w.WatchedSeasons, entity.WatchedSeason{
|
||||
UserID: userId,
|
||||
WatchedID: ar.WatchedID,
|
||||
SeasonNumber: ar.SeasonNumber,
|
||||
@@ -93,31 +90,31 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
if updated {
|
||||
if ar.Status != "" {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_STATUS_CHANGED, Data: string(json)})
|
||||
}
|
||||
if ar.Rating != 0 {
|
||||
json, _ := json.Marshal(map[string]interface{}{"season": ar.SeasonNumber, "rating": ar.Rating})
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: w.ID, Type: SEASON_RATING_CHANGED, Data: string(json)})
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: w.ID, Type: entity.SEASON_RATING_CHANGED, Data: string(json)})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
actData := map[string]interface{}{"season": ar.SeasonNumber, "status": ar.Status, "rating": ar.Rating}
|
||||
if len(ar.addActivityData) > 0 {
|
||||
for k, v := range ar.addActivityData {
|
||||
if _, ok := ar.addActivityData[k]; ok {
|
||||
if len(ar.AddActivityData) > 0 {
|
||||
for k, v := range ar.AddActivityData {
|
||||
if _, ok := ar.AddActivityData[k]; ok {
|
||||
actData[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
json, _ := json.Marshal(actData)
|
||||
act := ActivityAddRequest{WatchedID: w.ID, Type: SEASON_ADDED, Data: string(json)}
|
||||
if ar.addActivity != "" {
|
||||
act.Type = ar.addActivity
|
||||
act := activity.ActivityAddRequest{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
|
||||
if !ar.AddActivityDate.IsZero() {
|
||||
act.CustomDate = &ar.AddActivityDate
|
||||
}
|
||||
addedActivity, _ = addActivity(db, userId, act)
|
||||
addedActivity, _ = activity.AddActivity(db, userId, act)
|
||||
}
|
||||
return WatchedSeasonAddResponse{
|
||||
WatchedSeasons: w.WatchedSeasons,
|
||||
@@ -126,17 +123,17 @@ func addWatchedSeason(db *gorm.DB, userId uint, ar WatchedSeasonAddRequest) (Wat
|
||||
}
|
||||
|
||||
// Remove a watched season
|
||||
func rmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (Activity, error) {
|
||||
func (s *Service) RmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (entity.Activity, error) {
|
||||
slog.Debug("rmWatchedSeason called", "user_id", userId, "season_id", seasonId)
|
||||
var watchedSeason WatchedSeason
|
||||
resp := db.Clauses(clause.Returning{}).Model(&WatchedSeason{}).Unscoped().Where("id = ? AND user_id = ?", seasonId, userId).Delete(&watchedSeason)
|
||||
var watchedSeason entity.WatchedSeason
|
||||
resp := 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 Activity{}, errors.New("failed when removing watched season")
|
||||
return entity.Activity{}, errors.New("failed when removing watched season")
|
||||
}
|
||||
if resp.RowsAffected == 0 {
|
||||
slog.Error("Failed when removing a watched season", "error", "zero rows affected")
|
||||
return Activity{}, errors.New("wasn't removed from db.. may not exist")
|
||||
return entity.Activity{}, errors.New("wasn't removed from db.. may not exist")
|
||||
}
|
||||
slog.Debug("rmWatchedSeason, deleted row", "row", watchedSeason)
|
||||
if watchedSeason.ID != 0 {
|
||||
@@ -145,20 +142,20 @@ func rmWatchedSeason(db *gorm.DB, userId uint, seasonId uint) (Activity, error)
|
||||
"status": watchedSeason.Status,
|
||||
"rating": watchedSeason.Rating,
|
||||
})
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: watchedSeason.WatchedID, Type: SEASON_REMOVED, Data: string(json)})
|
||||
addedActivity, _ := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watchedSeason.WatchedID, Type: entity.SEASON_REMOVED, Data: string(json)})
|
||||
return addedActivity, nil
|
||||
}
|
||||
return Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
return entity.Activity{}, errors.New("removed, but failed to add activity entry")
|
||||
}
|
||||
|
||||
func getWatchedSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int) (*WatchedSeason, error) {
|
||||
var ws *WatchedSeason
|
||||
if res := db.Model(&WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNumber, userId).Take(&ws); res.Error != nil {
|
||||
func (s *Service) GetWatchedSeason(db *gorm.DB, userId uint, watchedId uint, seasonNumber int) (*entity.WatchedSeason, error) {
|
||||
var ws *entity.WatchedSeason
|
||||
if res := db.Model(&entity.WatchedSeason{}).Where("watched_id = ? AND season_number = ? AND user_id = ?", watchedId, seasonNumber, userId).Take(&ws); res.Error != nil {
|
||||
slog.Error("getWatchedSeason: Failed to get:", "error", res.Error.Error())
|
||||
if errors.Is(res.Error, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return &WatchedSeason{}, errors.New("failed to get watched season")
|
||||
return &entity.WatchedSeason{}, errors.New("failed to get watched season")
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
package watched
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/activity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status entity.WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType entity.ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
// Pass a watched date and we will set the CreatedAt (and initial UpdatedAt)
|
||||
// properties for this watched entry to this specific date.
|
||||
WatchedDate time.Time `json:"watchedDate,omitempty"`
|
||||
}
|
||||
|
||||
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"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
NewActivity entity.Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
type WatchedRemoveResponse struct {
|
||||
NewActivity entity.Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
// Get watched page request extra (GET) options.
|
||||
type WatchedGetPageRequest struct {
|
||||
// Sorting type.
|
||||
Sort WatchedSort `form:"sort"`
|
||||
// Sorting direction (asc or desc).
|
||||
SortDir SortDirection `form:"sortDir,default=desc"`
|
||||
// Filtering options.
|
||||
Filter struct {
|
||||
Type util.SupportedMedia `form:"filter.type"`
|
||||
Status entity.WatchedStatus `form:"filter.status"`
|
||||
}
|
||||
}
|
||||
|
||||
type WatchedSort string
|
||||
|
||||
const (
|
||||
watchedSortDateAdded WatchedSort = "DATEADDED"
|
||||
watchedSortLastChanged WatchedSort = "LASTCHANGED"
|
||||
watchedSortLastFinished WatchedSort = "LASTFIN"
|
||||
watchedSortRating WatchedSort = "RATING"
|
||||
watchedSortAlphabetical WatchedSort = "ALPHA"
|
||||
)
|
||||
|
||||
type SortDirection string
|
||||
|
||||
const (
|
||||
sortAscending SortDirection = "asc"
|
||||
sortDescending SortDirection = "desc"
|
||||
)
|
||||
|
||||
type ContentProvider interface {
|
||||
GetOrCacheContent(db *gorm.DB, contentType entity.ContentType, tmdbId int) (entity.Content, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
cp ContentProvider
|
||||
}
|
||||
|
||||
func NewService(cp ContentProvider) *Service {
|
||||
return &Service{
|
||||
cp: cp,
|
||||
}
|
||||
}
|
||||
|
||||
// Get entire watched list
|
||||
func (s *Service) getWatched(db *gorm.DB, userId uint) ([]entity.Watched, error) {
|
||||
watched := new([]entity.Watched)
|
||||
res := db.Model(&entity.Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatched: Failed!", "error", res.Error)
|
||||
return []entity.Watched{}, res.Error
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Returns a page of users watched list.
|
||||
func (s *Service) getWatchedPage(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
pp util.PaginationParams,
|
||||
wr WatchedGetPageRequest,
|
||||
) (util.PaginationResponse[entity.Watched], error) {
|
||||
slog.Debug("getWatchedPage: A page was requested.", "user_id", userId, "pagination_params", pp, "wr", wr)
|
||||
watched := new([]entity.Watched)
|
||||
pRes := &util.PaginationResponse[entity.Watched]{}
|
||||
res := db.
|
||||
Model(&entity.Watched{}).
|
||||
Where(&entity.Watched{UserID: userId}).
|
||||
Count(&pRes.TotalResults).
|
||||
Joins("Content").
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Tags").
|
||||
Scopes(
|
||||
util.Paginate(pp, pRes),
|
||||
// NOTE: watchedRefine->watchedSortLastFinished sort changes the SELECT
|
||||
// statement for this query, so keep that in mind if it ever changes.
|
||||
watchedRefine(wr),
|
||||
).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedPage: Failed!", "error", res.Error)
|
||||
return util.PaginationResponse[entity.Watched]{}, res.Error
|
||||
}
|
||||
pRes.Results = *watched
|
||||
pRes.Finished(pp)
|
||||
return *pRes, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
func (s *Service) GetWatchedItemById(db *gorm.DB, userId uint, id uint) (entity.Watched, error) {
|
||||
watched := new(entity.Watched)
|
||||
res := 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
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by content (tmdb) id (must be for `userId`).
|
||||
func (s *Service) GetWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType entity.ContentType) (entity.Watched, error) {
|
||||
slog.Debug("GetWatchedItemByTmdbId: Running.", "userId", userId, "tmdbId", tmdbId)
|
||||
watched := new(entity.Watched)
|
||||
res := db.Model(&entity.Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ? AND Content.tmdb_id = ? AND Content.type = ?", userId, tmdbId, contentType).
|
||||
Take(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("GetWatchedItemByTmdbId: Failed!", "error", res.Error)
|
||||
return entity.Watched{}, res.Error
|
||||
}
|
||||
slog.Debug("GetWatchedItemByTmdbId: Done.", "userId", userId, "tmdbId", tmdbId, "watched_item", watched)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Same as `getWatchedItemByTmdbId` except for getting in bulk (multiple content ids).
|
||||
// `c` entries should be in format: [tmdb_id, ContentType] (Note: Couldn't figure out
|
||||
// if it's possible to type this to enforce [int, ContentType] type for entries)
|
||||
func (s *Service) GetWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]entity.Watched, error) {
|
||||
slog.Debug("GetWatchedItemsByTmdbIds: Running.", "userId", userId, "c", c)
|
||||
watched := new([]entity.Watched)
|
||||
res := db.Model(&entity.Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Where(
|
||||
"(Content.tmdb_id, Content.type) IN ?", c).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("GetWatchedItemsByTmdbIds: Failed!", "error", res.Error)
|
||||
return []entity.Watched{}, res.Error
|
||||
}
|
||||
slog.Debug(
|
||||
"GetWatchedItemsByTmdbIds: Done.",
|
||||
"userId", userId,
|
||||
"watcheds_found", len(*watched),
|
||||
// "wdev", *watched,
|
||||
)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by game (igdb) id (must be for `userId`).
|
||||
// TODO update var names soon
|
||||
func (s *Service) getWatchedItemByIgdbId(db *gorm.DB, userId uint, tmdbId uint) (entity.Watched, error) {
|
||||
slog.Debug("getWatchedItemByIgdbId: Running.", "userId", userId, "tmdbId", tmdbId)
|
||||
watched := new(entity.Watched)
|
||||
res := db.Model(&entity.Watched{}).
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("Tags").
|
||||
Where("user_id = ? AND Game.igdb_id = ?", userId, tmdbId).
|
||||
Take(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemByIgdbId: Failed!", "error", res.Error)
|
||||
return entity.Watched{}, res.Error
|
||||
}
|
||||
slog.Debug("getWatchedItemByIgdbId: Done.", "userId", userId, "tmdbId", tmdbId, "watched_item", watched)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Same as `getWatchedItemByIgdbId` except for getting in bulk (multiple content ids).
|
||||
// `c` should be a slice of igdb ids.
|
||||
func (s *Service) getWatchedItemsByIgdbIds(db *gorm.DB, userId uint, c []int) ([]entity.Watched, error) {
|
||||
slog.Debug("getWatchedItemsByIgdbIds: Running.", "userId", userId, "c", c)
|
||||
watched := new([]entity.Watched)
|
||||
res := db.Model(&entity.Watched{}).
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Where("(Game.igdb_id) IN ?", c).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemsByIgdbIds: Failed!", "error", res.Error)
|
||||
return []entity.Watched{}, res.Error
|
||||
}
|
||||
slog.Debug(
|
||||
"getWatchedItemsByIgdbIds: Done.",
|
||||
"userId", userId,
|
||||
"watcheds_found", len(*watched),
|
||||
// "wdev", *watched,
|
||||
)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func (s *Service) getPublicWatched(db *gorm.DB, userId uint, username string) ([]entity.Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
// First we need to make sure the users list is public
|
||||
user := new(entity.User)
|
||||
// Figure we require knowlege of the users id and name to make it
|
||||
// harder to just type in random ids and see someones list.. dunno
|
||||
// if this is a thing we need but its here.. for now at least.
|
||||
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get user for getPublicWatched request", "user_id", userId)
|
||||
return []entity.Watched{}, errors.New("failed to check privacy settings")
|
||||
}
|
||||
if user.Private != nil && *user.Private {
|
||||
slog.Error("getPublicWatched attempted to get a private list", "user_id", userId)
|
||||
return []entity.Watched{}, errors.New("this watched list is private")
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]entity.Watched)
|
||||
res = db.Model(&entity.Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at entity.ActivityType) (entity.Watched, error) {
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
// Get content cache (or cache it if we don't have it locally)
|
||||
content, err := s.cp.GetOrCacheContent(db, ar.ContentType, ar.ContentID)
|
||||
if err != nil {
|
||||
return entity.Watched{}, err
|
||||
}
|
||||
// Error if content has no id
|
||||
if content.ID == 0 {
|
||||
return entity.Watched{}, errors.New("failed to find content id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
// Set default status for when content is added by
|
||||
// rating it instead of giving status first.
|
||||
if ar.ContentType == "movie" {
|
||||
ar.Status = entity.FINISHED
|
||||
} else {
|
||||
ar.Status = entity.WATCHING
|
||||
}
|
||||
}
|
||||
watched := entity.Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: &content.ID}
|
||||
if ar.Thoughts != "" {
|
||||
watched.Thoughts = ar.Thoughts
|
||||
}
|
||||
// If custom WatchedDate passed, set CreatedAt and UpdatedAt fields to it.
|
||||
if !ar.WatchedDate.IsZero() {
|
||||
slog.Debug("Adding watched item: The provided WatchedDate is valid.", "watched_date", ar.WatchedDate, "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
watched.CreatedAt = ar.WatchedDate
|
||||
watched.UpdatedAt = ar.WatchedDate
|
||||
}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&entity.Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return entity.Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return watched, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addWatched: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&entity.Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
watched.Thoughts = ar.Thoughts
|
||||
if res.Error != nil {
|
||||
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return entity.Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return entity.Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var act entity.Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
act, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
act, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, act)
|
||||
watched.Content = &content
|
||||
return watched, nil
|
||||
}
|
||||
|
||||
// this method is too ugly to look at please make him look better, future irhm
|
||||
func (s *Service) updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (WatchedUpdateResponse, error) {
|
||||
slog.Debug("UpdateWatched", "request_data", ar)
|
||||
upwat := entity.Watched{}
|
||||
res := db.Model(&entity.Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
|
||||
if res.Error != nil {
|
||||
slog.Error("Watched entry update failed:", "id", id, "error", res.Error.Error())
|
||||
return WatchedUpdateResponse{}, errors.New("failed to update watched entry")
|
||||
}
|
||||
originalThoughts := upwat.Thoughts
|
||||
if ar.Rating != 0 {
|
||||
upwat.Rating = ar.Rating
|
||||
}
|
||||
if ar.Status != "" {
|
||||
upwat.Status = ar.Status
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
upwat.Thoughts = ar.Thoughts
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
upwat.Thoughts = ""
|
||||
}
|
||||
if ar.Pinned != nil {
|
||||
upwat.Pinned = *ar.Pinned
|
||||
}
|
||||
res = db.Save(upwat)
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity := entity.Activity{}
|
||||
if ar.Rating != 0 {
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: id, Type: entity.RATING_CHANGED, Data: strconv.Itoa(int(ar.Rating))})
|
||||
}
|
||||
if ar.Status != "" {
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: id, Type: entity.STATUS_CHANGED, Data: string(ar.Status)})
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: id, Type: entity.THOUGHTS_CHANGED})
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
addedActivity, _ = activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: id, Type: entity.THOUGHTS_REMOVED, Data: originalThoughts})
|
||||
}
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error {
|
||||
slog.Debug("UpdateWatchedLastViewedSeason", "user_id", userId, "id", id, "season_num", seasonNum)
|
||||
res := db.
|
||||
Model(&entity.Watched{}).
|
||||
Where("id = ? AND user_id = ?", id, userId).
|
||||
Update("last_viewed_season", seasonNum)
|
||||
if res.Error != nil {
|
||||
slog.Error("updateWatchedLastViewedSeason: Failed when updating.", "error", res.Error)
|
||||
return errors.New("failed to update db")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// likely the watched entry does not exist or is not owned by this `userId`.
|
||||
slog.Error("updateWatchedLastViewedSeason: Watched entry does not exist.")
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
// Since other tables (eg activities) will link their rows to a watched_id, it's best to soft
|
||||
// delete, so if user restores watched item they still have activity for example (also so
|
||||
// someone else wont get other users activity if auto increment gives them the same watched id).
|
||||
res := db.Model(&entity.Watched{}).Where("id = ? AND user_id = ?", id, userId).Delete(&entity.Watched{})
|
||||
if res.Error != nil {
|
||||
slog.Error("Removing watched entry failed", "id", id, "error", res.Error.Error())
|
||||
return WatchedRemoveResponse{}, errors.New("failed to remove watched entry")
|
||||
}
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedRemoveResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity, _ := activity.AddActivity(db, userId, activity.ActivityAddRequest{WatchedID: id, Type: entity.REMOVED_WATCHED})
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
// Watched sorting & filtering.
|
||||
|
||||
package main
|
||||
package watched
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
@@ -0,0 +1,76 @@
|
||||
package watched
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Add watched content to a tag (user must own the tag and watched entry).
|
||||
func AddWatchedToTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("addWatchedToTag: Adding", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w entity.Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t entity.Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Save relation (unique restraint will fail if it already exists)
|
||||
w.Tags = append(w.Tags, t)
|
||||
resp := db.Save(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to tag watched item", "error", resp.Error)
|
||||
return errors.New("failed to tag watched item")
|
||||
}
|
||||
slog.Debug("addWatchedToTag: watched content successfully linked to tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove watched content from a tag (user must own the tag and watched entry).
|
||||
func RmWatchedFromTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("rmWatchedFromTag: Removing", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w entity.Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t entity.Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Remove relation
|
||||
err := db.Model(&w).Association("Tags").Delete(&t)
|
||||
if err != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to untag watched item", "error", err)
|
||||
return errors.New("failed to untag watched item")
|
||||
}
|
||||
slog.Debug("rmWatchedFromTag: watched content successfully removed from tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/sbondCo/Watcharr/game"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GameDetailsResponseWithPlayed struct {
|
||||
game.GameDetailsResponseBase
|
||||
SimilarGame []GameSimilarWithWatched `json:"similar_games"`
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
type GameSimilarWithWatched struct {
|
||||
game.GameSimilar
|
||||
WatchedAddedToContent
|
||||
}
|
||||
|
||||
func gameDetailsAddWatched(
|
||||
db *gorm.DB,
|
||||
userId uint,
|
||||
content game.GameDetailsResponse,
|
||||
) GameDetailsResponseWithPlayed {
|
||||
withWatchedResp := GameDetailsResponseWithPlayed{}
|
||||
withWatchedResp.GameDetailsResponseBase = content.GameDetailsResponseBase
|
||||
// Append watched list entry if exists
|
||||
if watchedEntry, err := getWatchedItemByIgdbId(db, userId, uint(content.ID)); err != nil {
|
||||
if err != gorm.ErrRecordNotFound {
|
||||
withWatchedResp.FailedToGetWatched = true
|
||||
}
|
||||
} else {
|
||||
withWatchedResp.Watched = &watchedEntry
|
||||
}
|
||||
// Add similar content with any watched entries
|
||||
similarContentIds := []int{}
|
||||
for _, v := range content.SimilarGame {
|
||||
withWatchedResp.SimilarGame = append(
|
||||
withWatchedResp.SimilarGame,
|
||||
GameSimilarWithWatched{
|
||||
GameSimilar: v,
|
||||
},
|
||||
)
|
||||
similarContentIds = append(similarContentIds, v.ID)
|
||||
}
|
||||
if ws, err := getWatchedItemsByIgdbIds(db, userId, similarContentIds); err == nil {
|
||||
for _, v := range ws {
|
||||
for i, vv := range withWatchedResp.SimilarGame {
|
||||
if vv.ID == v.Game.IgdbID {
|
||||
withWatchedResp.SimilarGame[i].WatchedAddedToContent.Watched = &v
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// TODO Set 'FailedToGetWatched' to `true` for the whole response obj when supported in structs
|
||||
slog.Error("Getting watched items by igdbIds failed!")
|
||||
}
|
||||
return withWatchedResp
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module github.com/sbondCo/Watcharr
|
||||
|
||||
go 1.24
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/buckket/go-blurhash v1.1.0
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
logLevel = new(slog.LevelVar)
|
||||
)
|
||||
|
||||
// Setup slog defaults
|
||||
func Setup(logfp string) io.Writer {
|
||||
multiw := io.MultiWriter(&lumberjack.Logger{
|
||||
Filename: logfp,
|
||||
MaxSize: 1, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
Compress: false,
|
||||
}, os.Stdout)
|
||||
slog.SetDefault(slog.New(
|
||||
slog.NewTextHandler(multiw, &slog.HandlerOptions{Level: logLevel}),
|
||||
))
|
||||
return multiw
|
||||
}
|
||||
|
||||
// Set loggin level from config
|
||||
func SetLevel(debug bool) {
|
||||
if debug {
|
||||
logLevel.Set(slog.LevelDebug)
|
||||
} else {
|
||||
logLevel.Set(slog.LevelInfo)
|
||||
}
|
||||
slog.Info("Logging level set", "logging_level", logLevel)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package game
|
||||
package igdb
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -1,4 +1,4 @@
|
||||
package game
|
||||
package igdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -1,13 +1,10 @@
|
||||
package main
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/addedtocontent"
|
||||
)
|
||||
|
||||
// Separated from `TMDBSearchResponse` so we can embed it for
|
||||
@@ -63,11 +60,26 @@ type TMDBSearchMultiResults struct {
|
||||
SeasonNumber int `json:"season_number,omitempty"`
|
||||
ShowId int `json:"show_id,omitempty"`
|
||||
StillPath string `json:"still_path,omitempty"`
|
||||
|
||||
//
|
||||
Watched *entity.Watched
|
||||
}
|
||||
|
||||
func (t TMDBSearchMultiResults) GetId() int {
|
||||
return t.ID
|
||||
}
|
||||
|
||||
func (t TMDBSearchMultiResults) AddWatched(w *entity.Watched) {
|
||||
t.Watched = w
|
||||
}
|
||||
|
||||
func (t TMDBSearchMultiResults) GetMediaType() string {
|
||||
return t.MediaType
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResultsWithWatched struct {
|
||||
TMDBSearchMultiResults
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchMultiResponse struct {
|
||||
@@ -97,7 +109,7 @@ type TMDBSearchMovieResult struct {
|
||||
|
||||
type TMDBSearchMovieResultWithWatched struct {
|
||||
TMDBSearchMovieResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchMoviesResponse struct {
|
||||
@@ -127,7 +139,7 @@ type TMDBSearchShowsResult struct {
|
||||
|
||||
type TMDBSearchShowsResultWithWatched struct {
|
||||
TMDBSearchShowsResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBSearchShowsResponse struct {
|
||||
@@ -241,7 +253,7 @@ type TMDBMovieDetails struct {
|
||||
}
|
||||
|
||||
type TMDBMovieDetailsWithWatched struct {
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
TMDBMovieDetailsBase
|
||||
Similar TMDBMovieSimilarWithWatched `json:"similar"`
|
||||
}
|
||||
@@ -309,7 +321,7 @@ type TMDBShowDetails struct {
|
||||
}
|
||||
|
||||
type TMDBShowDetailsWithWatched struct {
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
TMDBShowDetailsBase
|
||||
Similar TMDBShowSimilarWithWatched `json:"similar"`
|
||||
}
|
||||
@@ -413,7 +425,7 @@ type TMDBShowSimilarWithWatched struct {
|
||||
|
||||
type TMDBShowSimilarResultWithWatched struct {
|
||||
TMDBShowSimilarResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBMovieSimilar struct {
|
||||
@@ -443,7 +455,7 @@ type TMDBMovieSimilarWithWatched struct {
|
||||
|
||||
type TMDBMovieSimilarResultWithWatched struct {
|
||||
TMDBMovieSimilarResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBPersonDetails struct {
|
||||
@@ -552,7 +564,7 @@ type TMDBDiscoverMoviesResult struct {
|
||||
|
||||
type TMDBDiscoverMoviesResultWithWatched struct {
|
||||
TMDBDiscoverMoviesResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBDiscoverShows struct {
|
||||
@@ -581,7 +593,7 @@ type TMDBDiscoverShowsResult struct {
|
||||
|
||||
type TMDBDiscoverShowsResultWithWatched struct {
|
||||
TMDBDiscoverShowsResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBTrendingAll struct {
|
||||
@@ -616,7 +628,7 @@ type TMDBTrendingAllResult struct {
|
||||
|
||||
type TMDBTrendingAllResultWithWatched struct {
|
||||
TMDBTrendingAllResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBUpcomingMovies struct {
|
||||
@@ -654,7 +666,7 @@ type TMDBUpcomingMoviesResult struct {
|
||||
|
||||
type TMDBUpcomingMoviesResultWithWatched struct {
|
||||
TMDBUpcomingMoviesResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBUpcomingShows struct {
|
||||
@@ -683,7 +695,7 @@ type TMDBUpcomingShowsResult struct {
|
||||
|
||||
type TMDBUpcomingShowsResultWithWatched struct {
|
||||
TMDBUpcomingShowsResult
|
||||
WatchedAddedToContent
|
||||
addedtocontent.WatchedAddedToContent
|
||||
}
|
||||
|
||||
type TMDBExternalIds struct {
|
||||
@@ -722,60 +734,3 @@ type TMDBRegions struct {
|
||||
Native_Name string `json:"native_name"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func getTMDBKey() string {
|
||||
if Config.TMDB_KEY != "" {
|
||||
return Config.TMDB_KEY
|
||||
}
|
||||
return "d047fa61d926371f277e7a83c9c4ff2c"
|
||||
}
|
||||
|
||||
func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse("https://api.themoviedb.org/3")
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
params.Add("api_key", getTMDBKey())
|
||||
params.Add("language", "en-US")
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
// Run get request
|
||||
res, err := http.Get(base.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("TMDB non 200 status code:", "status_code", res.StatusCode)
|
||||
return nil, errors.New(string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func tmdbRequest(ep string, p map[string]string, resp interface{}) error {
|
||||
body, err := tmdbAPIRequest(ep, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// TODO rewrite tmdb to work like how igdb package was made
|
||||
// TODO The *WithWatched structs likely need to go in the watched package (or with go 1.25 can we
|
||||
// fix needing so many extra structs for the *WithWatched types and functions)
|
||||
|
||||
type TMDB struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func NewTMDB(key string) *TMDB {
|
||||
return &TMDB{
|
||||
Key: key,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *TMDB) GetKey() string {
|
||||
if t.Key != "" {
|
||||
return t.Key //Config.TMDB_KEY
|
||||
}
|
||||
return "d047fa61d926371f277e7a83c9c4ff2c"
|
||||
}
|
||||
|
||||
func (t *TMDB) APIRequest(ep string, p map[string]string) ([]byte, error) {
|
||||
slog.Debug("tmdbAPIRequest", "endpoint", ep, "params", p)
|
||||
base, err := url.Parse("https://api.themoviedb.org/3")
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to parse api uri")
|
||||
}
|
||||
|
||||
// Path params
|
||||
base.Path += ep
|
||||
|
||||
// Query params
|
||||
params := url.Values{}
|
||||
params.Add("api_key", t.GetKey())
|
||||
params.Add("language", "en-US")
|
||||
for k, v := range p {
|
||||
params.Add(k, v)
|
||||
}
|
||||
|
||||
// Add params to url
|
||||
base.RawQuery = params.Encode()
|
||||
|
||||
// Run get request
|
||||
res, err := http.Get(base.String())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if res.StatusCode != 200 {
|
||||
slog.Error("TMDB non 200 status code:", "status_code", res.StatusCode)
|
||||
return nil, errors.New(string(body))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func (t *TMDB) Request(ep string, p map[string]string, resp interface{}) error {
|
||||
body, err := t.APIRequest(ep, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = json.Unmarshal([]byte(body), &resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
package main
|
||||
// TODO move this to a middleware package.
|
||||
|
||||
package router
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -6,18 +8,23 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
)
|
||||
|
||||
// Location middleware
|
||||
func WhereaboutsRequired() gin.HandlerFunc {
|
||||
func WhereaboutsRequired(cfg *config.ServerConfig) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
region := c.Query("region")
|
||||
slog.Debug("WhereaboutsRequired: middleware hit", "region", region)
|
||||
if region == "" {
|
||||
// If no region is passed, default to server region.
|
||||
if Config.DEFAULT_COUNTRY != "" {
|
||||
slog.Debug("WhereaboutsRequired: Using server default country.", "default_country", Config.DEFAULT_COUNTRY)
|
||||
c.Set("userCountry", Config.DEFAULT_COUNTRY)
|
||||
if cfg.DEFAULT_COUNTRY != "" {
|
||||
slog.Debug(
|
||||
"WhereaboutsRequired: Using server default country.",
|
||||
"default_country", cfg.DEFAULT_COUNTRY,
|
||||
)
|
||||
c.Set("userCountry", cfg.DEFAULT_COUNTRY)
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
@@ -72,7 +79,7 @@ func PaginatedRequest(force bool) gin.HandlerFunc {
|
||||
}
|
||||
slog.Debug("PossiblyPaginated: middleware hit", "page", page, "page_limit", limit)
|
||||
c.Set("paginationEnabled", true)
|
||||
c.Set("paginationParams", PaginationParams{
|
||||
c.Set("paginationParams", util.PaginationParams{
|
||||
Page: page,
|
||||
Limit: limit,
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cache/persistence"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
// TODO don't use generic ValueRequest and KeyValueRequest..
|
||||
// each handler/service should define their own struct.
|
||||
|
||||
type ValueRequest struct {
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type KeyValueRequest struct {
|
||||
Key string `json:"key"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
type BaseRouter struct {
|
||||
// Our database.
|
||||
DB *gorm.DB
|
||||
// Our base router group.
|
||||
Router *gin.RouterGroup
|
||||
// Our in-memory store used for cache.
|
||||
MemStore *persistence.InMemoryStore
|
||||
// Our server config.
|
||||
Cfg *config.ServerConfig
|
||||
}
|
||||
|
||||
func NewBaseRouter(db *gorm.DB, rg *gin.RouterGroup, cfg *config.ServerConfig) *BaseRouter {
|
||||
return &BaseRouter{
|
||||
DB: db,
|
||||
Router: rg,
|
||||
MemStore: persistence.NewInMemoryStore(time.Hour * 24),
|
||||
Cfg: cfg,
|
||||
}
|
||||
}
|
||||
-1823
File diff suppressed because it is too large
Load Diff
-194
@@ -1,194 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// I think tags will be private for the user.
|
||||
// If the user wants to make a public list, they should make a custom view.
|
||||
|
||||
type Tag struct {
|
||||
GormModel
|
||||
// ID of user that own this tag.
|
||||
UserID uint `json:"-" gorm:"not null"`
|
||||
// Name of the tag.
|
||||
Name string `json:"name" gorm:"not null"`
|
||||
// Hex of text color.
|
||||
Color string `json:"color"`
|
||||
// Hex of background color.
|
||||
BgColor string `json:"bgColor"`
|
||||
// All watched items.
|
||||
Watched []Watched `json:"watched,omitempty" gorm:"many2many:watched_tags;"`
|
||||
}
|
||||
|
||||
type TagAddRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Color string `json:"color"`
|
||||
BgColor string `json:"bgColor"`
|
||||
}
|
||||
|
||||
func getTags(db *gorm.DB, userId uint) ([]Tag, error) {
|
||||
tags := new([]Tag)
|
||||
res := db.Model(&Tag{}).Where("user_id = ?", userId).Find(&tags)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTags: Failed getting tags from database", "error", res.Error.Error())
|
||||
return []Tag{}, errors.New("failed getting tags")
|
||||
}
|
||||
return *tags, nil
|
||||
}
|
||||
|
||||
// func getTag(db *gorm.DB, userId uint, tagId uint) (Tag, error) {
|
||||
// tag := new(Tag)
|
||||
// res := db.Model(&Tag{}).Where("id = ? AND user_id = ?", tagId, userId).Preload("Watched").Find(&tag)
|
||||
// if res.Error != nil {
|
||||
// slog.Error("getTag: Failed getting tag from database", "error", res.Error.Error())
|
||||
// return Tag{}, errors.New("failed getting tag")
|
||||
// }
|
||||
// if tag.ID == 0 {
|
||||
// slog.Error("getTag: Tag does not exist for this user.", "user_id", userId)
|
||||
// return Tag{}, errors.New("tag does not exist")
|
||||
// }
|
||||
// return *tag, nil
|
||||
// }
|
||||
|
||||
// This method should only be used when we don't have the tagId
|
||||
// (eg: when we are importing data) because this is not technically
|
||||
// reliable, since users can have multiple tags with the same name/colors
|
||||
// (realistically they probably won't, but...).
|
||||
func getTagByNameAndColor(db *gorm.DB, userId uint, tagName string, tagColor string, tagBgColor string) (Tag, error) {
|
||||
tag := new(Tag)
|
||||
res := db.Model(&Tag{}).Where("name = ? AND user_id = ? AND color = ? AND bg_color = ?", tagName, userId, tagColor, tagBgColor).Preload("Watched").Find(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("getTagByNameAndColor: Failed getting tag from database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed getting tag")
|
||||
}
|
||||
if tag.ID == 0 {
|
||||
slog.Error("getTagByNameAndColor: Tag does not exist for this user.", "user_id", userId)
|
||||
return Tag{}, errors.New("tag does not exist")
|
||||
}
|
||||
return *tag, nil
|
||||
}
|
||||
|
||||
// Let user create a tag.
|
||||
func addTag(db *gorm.DB, userId uint, tr TagAddRequest) (Tag, error) {
|
||||
if tr.Name == "" {
|
||||
return Tag{}, errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{UserID: userId, Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Create(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error adding tag to database", "error", res.Error.Error())
|
||||
return Tag{}, errors.New("failed adding new tag to database")
|
||||
}
|
||||
slog.Debug("Adding tag", "added_tag", tag)
|
||||
return tag, nil
|
||||
}
|
||||
|
||||
// Let user update one of their tags (replaces).
|
||||
func updateTag(db *gorm.DB, userId uint, tagId uint, tr TagAddRequest) error {
|
||||
if tr.Name == "" {
|
||||
return errors.New("tag must have a name")
|
||||
}
|
||||
tag := Tag{Name: tr.Name, Color: tr.Color, BgColor: tr.BgColor}
|
||||
res := db.Where("id = ? AND user_id = ?", tagId, userId).Updates(&tag)
|
||||
if res.Error != nil {
|
||||
slog.Error("Error updating tag in database", "error", res.Error.Error())
|
||||
return errors.New("failed updating tag in database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("updateTag: Zero rows affected.. tag likely does not exist", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
slog.Debug("updateTag:", "updated_tag", tag)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Let user delete their own tag.
|
||||
func deleteTag(db *gorm.DB, userId uint, tagId uint) error {
|
||||
if tagId == 0 {
|
||||
return errors.New("no tag id provided")
|
||||
}
|
||||
slog.Debug("deleteTag:", "tag_id", tagId, "user_id", userId)
|
||||
// Select("Watched") so relations in watched_tags table are removed too.
|
||||
// ID is passed in the .Delete param so the .Select call can do it's job (relies on the primary key).
|
||||
res := db.Unscoped().Where("id = ? AND user_id = ?", tagId, userId).Select("Watched").Delete(&Tag{GormModel: GormModel{ID: tagId}})
|
||||
if res.Error != nil {
|
||||
slog.Error("deleteTag: Error deleting tag from database", "error", res.Error.Error(), "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("failed deleting tag from database")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Error("deleteTag: Zero rows affected.. tag must not exist for user", "tag_id", tagId, "user_id", userId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add watched content to a tag (user must own the tag and watched entry).
|
||||
func addWatchedToTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("addWatchedToTag: Adding", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("addWatchedToTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Save relation (unique restraint will fail if it already exists)
|
||||
w.Tags = append(w.Tags, t)
|
||||
resp := db.Save(&w)
|
||||
if resp.Error != nil {
|
||||
slog.Error("addWatchedToTag: Failed to tag watched item", "error", resp.Error)
|
||||
return errors.New("failed to tag watched item")
|
||||
}
|
||||
slog.Debug("addWatchedToTag: watched content successfully linked to tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove watched content from a tag (user must own the tag and watched entry).
|
||||
func rmWatchedFromTag(db *gorm.DB, userId uint, tagId uint, watchedId uint) error {
|
||||
slog.Debug("rmWatchedFromTag: Removing", "userId", userId, "watchedID", watchedId, "tagId", tagId)
|
||||
// 1. Make sure watched item exists and is owned by this user
|
||||
var w Watched
|
||||
if resp := db.Where("id = ? AND user_id = ?", watchedId, userId).Preload("Tags").Find(&w); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: failed to get watched item from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving watched item")
|
||||
}
|
||||
if w.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "watched item does not exist in db", "watchedID", watchedId)
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
// 2. Make sure tag exists
|
||||
var t Tag
|
||||
if resp := db.Where("id = ? AND user_id = ?", tagId, userId).Find(&t); resp.Error != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to get tag from db", "error", resp.Error)
|
||||
return errors.New("failed when retrieving tag")
|
||||
}
|
||||
if t.ID == 0 {
|
||||
slog.Error("rmWatchedFromTag", "error", "tag does not exist in db", "tagId", tagId)
|
||||
return errors.New("tag does not exist")
|
||||
}
|
||||
// 3. Remove relation
|
||||
err := db.Model(&w).Association("Tags").Delete(&t)
|
||||
if err != nil {
|
||||
slog.Error("rmWatchedFromTag: Failed to untag watched item", "error", err)
|
||||
return errors.New("failed to untag watched item")
|
||||
}
|
||||
slog.Debug("rmWatchedFromTag: watched content successfully removed from tag", "watchedID", watchedId, "tagId", tagId)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package token
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/sbondCo/Watcharr/database/entity"
|
||||
"github.com/sbondCo/Watcharr/util"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const TokenMaxAge = 2 * time.Minute
|
||||
|
||||
func CreateOneUseToken(db *gorm.DB, t entity.TokenType, userId uint) (string, error) {
|
||||
token, err := util.GenerateString(8)
|
||||
if err != nil {
|
||||
slog.Error("createOneUseToken: Failed to generate string!", "error", err)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
res := db.Create(&entity.Token{Type: t, Value: token, UserID: userId})
|
||||
if res.Error != nil {
|
||||
slog.Error("createOneUseToken: Failed to insert token into db!", "error", res.Error)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Cleans up tokens older than 2m.
|
||||
func CleanupTokens(db *gorm.DB) {
|
||||
slog.Debug("cleanupTokens: Cleaning up old tokens from db")
|
||||
twoMinsAgo := time.Now().Add(-TokenMaxAge)
|
||||
resp := db.Where("created_at < ?", twoMinsAgo).Delete(&entity.Token{})
|
||||
if resp.Error != nil {
|
||||
slog.Error("cleanupTokens: Failed to run DELETE on old tokens!", "error", resp.Error)
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type TokenType string
|
||||
|
||||
var (
|
||||
TOKENTYPE_ADMIN TokenType = "ADMIN"
|
||||
)
|
||||
|
||||
type Token struct {
|
||||
ID uint `gorm:"primarykey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Value string `gorm:"not null"`
|
||||
Type TokenType `gorm:"not null"`
|
||||
UserID uint `gorm:"not null"`
|
||||
}
|
||||
|
||||
const tokenMaxAge = 2 * time.Minute
|
||||
|
||||
func createOneUseToken(db *gorm.DB, t TokenType, userId uint) (string, error) {
|
||||
token, err := generateString(8)
|
||||
if err != nil {
|
||||
slog.Error("createOneUseToken: Failed to generate string!", "error", err)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
res := db.Create(&Token{Type: t, Value: token, UserID: userId})
|
||||
if res.Error != nil {
|
||||
slog.Error("createOneUseToken: Failed to insert token into db!", "error", res.Error)
|
||||
return "", errors.New("failed to generate token")
|
||||
}
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// Cleans up tokens older than 2m.
|
||||
func cleanupTokens(db *gorm.DB) {
|
||||
slog.Debug("cleanupTokens: Cleaning up old tokens from db")
|
||||
twoMinsAgo := time.Now().Add(-tokenMaxAge)
|
||||
resp := db.Where("created_at < ?", twoMinsAgo).Delete(&Token{})
|
||||
if resp.Error != nil {
|
||||
slog.Error("cleanupTokens: Failed to run DELETE on old tokens!", "error", resp.Error)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
)
|
||||
|
||||
// Generate a random string
|
||||
func generateString(len int) (string, error) {
|
||||
func GenerateString(len int) (string, error) {
|
||||
key := make([]byte, len)
|
||||
_, err := rand.Read(key)
|
||||
if err != nil {
|
||||
@@ -1,4 +1,4 @@
|
||||
package main
|
||||
package util
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
@@ -0,0 +1,11 @@
|
||||
package util
|
||||
|
||||
// Types of media supported by Watcharr
|
||||
// in an overarching way.
|
||||
type SupportedMedia string
|
||||
|
||||
const (
|
||||
SupportedMediaMovie SupportedMedia = "movie"
|
||||
SupportedMediaShow SupportedMedia = "tv"
|
||||
SupportedMediaGame SupportedMedia = "game"
|
||||
)
|
||||
+69
-105
@@ -3,44 +3,33 @@ package main
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/user"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/joho/godotenv"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type GormModel struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"deletedAt"`
|
||||
}
|
||||
|
||||
// Types of media supported by Watcharr
|
||||
// in an overarching way.
|
||||
type SupportedMedia string
|
||||
|
||||
const (
|
||||
SupportedMediaMovie SupportedMedia = "movie"
|
||||
SupportedMediaShow SupportedMedia = "tv"
|
||||
SupportedMediaGame SupportedMedia = "game"
|
||||
)
|
||||
|
||||
var (
|
||||
ServerInSetup = false
|
||||
logLevel = new(slog.LevelVar)
|
||||
"github.com/sbondCo/Watcharr/config"
|
||||
"github.com/sbondCo/Watcharr/database"
|
||||
"github.com/sbondCo/Watcharr/feature/auth"
|
||||
"github.com/sbondCo/Watcharr/feature/content"
|
||||
"github.com/sbondCo/Watcharr/feature/feature"
|
||||
"github.com/sbondCo/Watcharr/feature/jellyfin"
|
||||
"github.com/sbondCo/Watcharr/feature/setup"
|
||||
"github.com/sbondCo/Watcharr/feature/task"
|
||||
"github.com/sbondCo/Watcharr/feature/watched"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/episode"
|
||||
"github.com/sbondCo/Watcharr/feature/watched/season"
|
||||
"github.com/sbondCo/Watcharr/logging"
|
||||
"github.com/sbondCo/Watcharr/media/tmdb"
|
||||
"github.com/sbondCo/Watcharr/router"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -52,21 +41,22 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
multiw := setupLogging()
|
||||
multiw := logging.Setup(path.Join(config.DataPath, "watcharr.log"))
|
||||
slog.Info("Watcharr Starting")
|
||||
|
||||
if err = readConfig(); err != nil {
|
||||
log.Fatal("Failed to read server config!", err)
|
||||
}
|
||||
|
||||
setLoggingLevel()
|
||||
|
||||
// Ensure data dir exists
|
||||
err = ensureDirExists(DataPath)
|
||||
err = ensureDirExists(config.DataPath)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to create data dir:", err)
|
||||
}
|
||||
|
||||
cfg, err := config.Get()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get server config!", err)
|
||||
}
|
||||
|
||||
logging.SetLevel(cfg.DEBUG)
|
||||
|
||||
// Check if we want to be in DEV or PROD
|
||||
isProd := true
|
||||
if os.Getenv("MODE") == "DEV" {
|
||||
@@ -74,30 +64,11 @@ func main() {
|
||||
isProd = false
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(path.Join(DataPath, "watcharr.db")), &gorm.Config{TranslateError: true})
|
||||
db, err := database.New()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to database:", err)
|
||||
}
|
||||
|
||||
err = db.AutoMigrate(
|
||||
&User{},
|
||||
&UserServices{},
|
||||
&Content{},
|
||||
&Watched{},
|
||||
&WatchedSeason{},
|
||||
&WatchedEpisode{},
|
||||
&Activity{},
|
||||
&Token{},
|
||||
&Follow{},
|
||||
&Image{},
|
||||
&Game{},
|
||||
&ArrRequest{},
|
||||
&Tag{},
|
||||
)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to auto migrate database:", err)
|
||||
}
|
||||
|
||||
if isProd {
|
||||
go runUI()
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
@@ -136,72 +107,65 @@ func main() {
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
br := newBaseRouter(db, gine.Group("/api"))
|
||||
api := gine.Group("/api")
|
||||
br := router.NewBaseRouter(db, api, cfg)
|
||||
// Only add setup routes if there are no users found in db.
|
||||
var userCount int64
|
||||
if uresp := db.Model(&User{}).Count(&userCount); uresp.Error == nil {
|
||||
if uresp := db.Model(&user.User{}).Count(&userCount); uresp.Error == nil {
|
||||
if userCount != 0 {
|
||||
slog.Debug("registered users found.. skipped creating setup routes.")
|
||||
} else {
|
||||
slog.Info("No users found.. creating setup routes.")
|
||||
ServerInSetup = true
|
||||
br.addSetupRoutes()
|
||||
setup.NewRouter(br).AddRoutes()
|
||||
}
|
||||
} else {
|
||||
slog.Error("Failed to check if any users exist.. not registering setup routes", "error", uresp.Error)
|
||||
}
|
||||
br.addAuthRoutes()
|
||||
br.addContentRoutes()
|
||||
br.addGameRoutes()
|
||||
br.addWatchedRoutes()
|
||||
br.addActivityRoutes()
|
||||
br.addProfileRoutes()
|
||||
br.addJellyfinRoutes()
|
||||
br.addPlexRoutes()
|
||||
br.addUserRoutes()
|
||||
br.addFollowRoutes()
|
||||
br.addImportRoutes()
|
||||
br.addServerRoutes()
|
||||
br.addFeatureRoutes()
|
||||
br.addSonarrRoutes()
|
||||
br.addRadarrRoutes()
|
||||
br.addArrRequestRoutes()
|
||||
br.addJobRoutes()
|
||||
br.addTaskRoutes()
|
||||
br.addTagRoutes()
|
||||
br.rg.Static("/img", path.Join(DataPath, "img"))
|
||||
// br.AddAuthRoutes()
|
||||
// br.AddContentRoutes()
|
||||
// br.AddGameRoutes()
|
||||
// br.AddWatchedRoutes()
|
||||
// br.AddActivityRoutes()
|
||||
// br.AddProfileRoutes()
|
||||
// br.AddJellyfinRoutes()
|
||||
// br.AddPlexRoutes()
|
||||
// br.AddUserRoutes()
|
||||
// br.AddFollowRoutes()
|
||||
// br.AddImportRoutes()
|
||||
// br.AddServerRoutes()
|
||||
// br.AddFeatureRoutes()
|
||||
// br.AddSonarrRoutes()
|
||||
// br.AddRadarrRoutes()
|
||||
// br.AddArrRequestRoutes()
|
||||
// br.AddJobRoutes()
|
||||
// br.AddTaskRoutes()
|
||||
// br.AddTagRoutes()
|
||||
api.Static("/img", path.Join(config.DataPath, "img"))
|
||||
|
||||
go setupTasks(db)
|
||||
t := tmdb.NewTMDB(cfg.TMDB_KEY)
|
||||
|
||||
contentService := content.NewService(t)
|
||||
watchedService := watched.NewService(contentService)
|
||||
watchedSeasonService := season.NewService()
|
||||
watchedEpisodeService := episode.NewService(watchedService, watchedSeasonService, contentService)
|
||||
jellyfinService := jellyfin.NewService(cfg)
|
||||
jellyfinSyncService := jellyfin.NewSyncService(cfg, jellyfinService, watchedService, watchedSeasonService, watchedEpisodeService)
|
||||
featureService := feature.NewService(cfg)
|
||||
|
||||
auth.NewRouter(br).AddRoutes()
|
||||
content.NewRouter(br, contentService, watchedService).AddRoutes()
|
||||
watched.NewRouter(br, t, watchedService).AddRoutes()
|
||||
season.NewRouter(br, watchedSeasonService).AddRoutes()
|
||||
episode.NewRouter(br, watchedEpisodeService).AddRoutes()
|
||||
task.NewRouter(br).AddRoutes()
|
||||
feature.NewRouter(br, featureService).AddRoutes()
|
||||
jellyfin.NewRouter(br, jellyfinService, jellyfinSyncService).AddRoutes()
|
||||
|
||||
go task.SetupTasks(cfg, db)
|
||||
|
||||
gine.Run("0.0.0.0:3080")
|
||||
}
|
||||
|
||||
// Setup slog defaults
|
||||
func setupLogging() io.Writer {
|
||||
// logLevel = new(slog.LevelVar)
|
||||
multiw := io.MultiWriter(&lumberjack.Logger{
|
||||
Filename: path.Join(DataPath, "watcharr.log"),
|
||||
MaxSize: 1, // megabytes
|
||||
MaxBackups: 3,
|
||||
MaxAge: 28, // days
|
||||
Compress: false,
|
||||
}, os.Stdout)
|
||||
slog.SetDefault(slog.New(
|
||||
slog.NewTextHandler(multiw, &slog.HandlerOptions{Level: logLevel}),
|
||||
))
|
||||
return multiw
|
||||
}
|
||||
|
||||
// Set loggin level from config
|
||||
func setLoggingLevel() {
|
||||
if Config.DEBUG {
|
||||
logLevel.Set(slog.LevelDebug)
|
||||
} else {
|
||||
logLevel.Set(slog.LevelInfo)
|
||||
}
|
||||
slog.Info("Logging level set", "logging_level", logLevel)
|
||||
}
|
||||
|
||||
// Run UI server
|
||||
func runUI() {
|
||||
cmd := exec.Command("node", "ui/index.js")
|
||||
|
||||
@@ -1,517 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type WatchedStatus string
|
||||
|
||||
const (
|
||||
FINISHED WatchedStatus = "FINISHED"
|
||||
WATCHING WatchedStatus = "WATCHING"
|
||||
PLANNED WatchedStatus = "PLANNED"
|
||||
HOLD WatchedStatus = "HOLD"
|
||||
DROPPED WatchedStatus = "DROPPED"
|
||||
)
|
||||
|
||||
type Watched struct {
|
||||
GormModel
|
||||
Status WatchedStatus `json:"status"`
|
||||
// float so we can support decimal ratings.
|
||||
// Ratings should still always be saved as out of 10.0,
|
||||
// so they can be viewed with any ratings setting in the client.
|
||||
Rating float64 `json:"rating" gorm:"type:numeric(2,1)"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
Pinned bool `json:"pinned" gorm:"default:false;not null"`
|
||||
UserID uint `json:"-" gorm:"uniqueIndex:usernctnidx;uniqueIndex:userngamidx"`
|
||||
ContentID *int `json:"-" gorm:"uniqueIndex:usernctnidx"`
|
||||
Content *Content `json:"content,omitempty"`
|
||||
GameID *int `json:"-" gorm:"uniqueIndex:userngamidx"`
|
||||
Game *Game `json:"game,omitempty"`
|
||||
Activity []Activity `json:"activity"`
|
||||
WatchedSeasons []WatchedSeason `json:"watchedSeasons,omitempty"` // For shows
|
||||
WatchedEpisodes []WatchedEpisode `json:"watchedEpisodes,omitempty"` // For shows
|
||||
Tags []Tag `json:"tags,omitempty" gorm:"many2many:watched_tags;"`
|
||||
// The last season that was viewed by the user for this watched entry.
|
||||
// Only applies to tv shows of course.
|
||||
LastViewedSeason *int `json:"lastViewedSeason,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedAddRequest struct {
|
||||
Status WatchedStatus `json:"status"`
|
||||
Rating float64 `json:"rating" binding:"max=10"`
|
||||
Thoughts string `json:"thoughts"`
|
||||
ContentID int `json:"contentId" binding:"required"`
|
||||
ContentType ContentType `json:"contentType" binding:"required,oneof=movie tv"`
|
||||
// Pass a watched date and we will set the CreatedAt (and initial UpdatedAt)
|
||||
// properties for this watched entry to this specific date.
|
||||
WatchedDate time.Time `json:"watchedDate,omitempty"`
|
||||
}
|
||||
|
||||
type WatchedUpdateRequest struct {
|
||||
Status 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"`
|
||||
RemoveThoughts bool `json:"removeThoughts"`
|
||||
Pinned *bool `json:"pinned" binding:"required_without_all=Status Rating Thoughts RemoveThoughts"`
|
||||
}
|
||||
|
||||
type WatchedUpdateResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
type WatchedRemoveResponse struct {
|
||||
NewActivity Activity `json:"newActivity"`
|
||||
}
|
||||
|
||||
// Get watched page request extra (GET) options.
|
||||
type WatchedGetPageRequest struct {
|
||||
// Sorting type.
|
||||
Sort WatchedSort `form:"sort"`
|
||||
// Sorting direction (asc or desc).
|
||||
SortDir SortDirection `form:"sortDir,default=desc"`
|
||||
// Filtering options.
|
||||
Filter struct {
|
||||
Type SupportedMedia `form:"filter.type"`
|
||||
Status WatchedStatus `form:"filter.status"`
|
||||
}
|
||||
}
|
||||
|
||||
type WatchedSort string
|
||||
|
||||
const (
|
||||
watchedSortDateAdded WatchedSort = "DATEADDED"
|
||||
watchedSortLastChanged WatchedSort = "LASTCHANGED"
|
||||
watchedSortLastFinished WatchedSort = "LASTFIN"
|
||||
watchedSortRating WatchedSort = "RATING"
|
||||
watchedSortAlphabetical WatchedSort = "ALPHA"
|
||||
)
|
||||
|
||||
type SortDirection string
|
||||
|
||||
const (
|
||||
sortAscending SortDirection = "asc"
|
||||
sortDescending SortDirection = "desc"
|
||||
)
|
||||
|
||||
// This struct is for embedding inside content response structs.
|
||||
// This holds the watched entry response data that will go along
|
||||
// with the content responses.
|
||||
type WatchedAddedToContent struct {
|
||||
// The related watched entry.
|
||||
Watched *Watched `json:"watched,omitempty"`
|
||||
// If we failed to get the watched entry,
|
||||
// set this to true, so the frontend can
|
||||
// notify the user of why there is possibly
|
||||
// missing watched list data.
|
||||
FailedToGetWatched bool `json:"failedToGetWatched,omitempty"`
|
||||
}
|
||||
|
||||
// Get entire watched list
|
||||
func getWatched(db *gorm.DB, userId uint) ([]Watched, error) {
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatched: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Returns a page of users watched list.
|
||||
func getWatchedPage(db *gorm.DB, userId uint, pp PaginationParams, wr WatchedGetPageRequest) (PaginationResponse[Watched], error) {
|
||||
slog.Debug("getWatchedPage: A page was requested.", "user_id", userId, "pagination_params", pp, "wr", wr)
|
||||
watched := new([]Watched)
|
||||
pRes := &PaginationResponse[Watched]{}
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where(&Watched{UserID: userId}).
|
||||
Count(&pRes.TotalResults).
|
||||
Joins("Content").
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Tags").
|
||||
Scopes(
|
||||
Paginate(pp, pRes),
|
||||
// NOTE: watchedRefine->watchedSortLastFinished sort changes the SELECT
|
||||
// statement for this query, so keep that in mind if it ever changes.
|
||||
watchedRefine(wr),
|
||||
).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedPage: Failed!", "error", res.Error)
|
||||
return PaginationResponse[Watched]{}, res.Error
|
||||
}
|
||||
pRes.Results = *watched
|
||||
pRes.Finished(pp)
|
||||
return *pRes, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by id (must be for `userId`).
|
||||
func getWatchedItemById(db *gorm.DB, userId uint, id uint) (Watched, error) {
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).Preload("Content").Where("user_id = ? AND id = ?", userId, id).Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemById: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by content (tmdb) id (must be for `userId`).
|
||||
func getWatchedItemByTmdbId(db *gorm.DB, userId uint, tmdbId uint, contentType ContentType) (Watched, error) {
|
||||
slog.Debug("getWatchedItemByTmdbId: Running.", "userId", userId, "tmdbId", tmdbId)
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ? AND Content.tmdb_id = ? AND Content.type = ?", userId, tmdbId, contentType).
|
||||
Take(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemByTmdbId: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
slog.Debug("getWatchedItemByTmdbId: Done.", "userId", userId, "tmdbId", tmdbId, "watched_item", watched)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Same as `getWatchedItemByTmdbId` except for getting in bulk (multiple content ids).
|
||||
// `c` entries should be in format: [tmdb_id, ContentType] (Note: Couldn't figure out
|
||||
// if it's possible to type this to enforce [int, ContentType] type for entries)
|
||||
func getWatchedItemsByTmdbIds(db *gorm.DB, userId uint, c [][]any) ([]Watched, error) {
|
||||
slog.Debug("getWatchedItemsByTmdbIds: Running.", "userId", userId, "c", c)
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Preload("Content").
|
||||
Preload("Activity").
|
||||
Preload("WatchedSeasons").
|
||||
Preload("WatchedEpisodes").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Where(
|
||||
"(Content.tmdb_id, Content.type) IN ?", c).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemsByTmdbIds: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
slog.Debug(
|
||||
"getWatchedItemsByTmdbIds: Done.",
|
||||
"userId", userId,
|
||||
"watcheds_found", len(*watched),
|
||||
// "wdev", *watched,
|
||||
)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get a watched list item by game (igdb) id (must be for `userId`).
|
||||
// TODO update var names soon
|
||||
func getWatchedItemByIgdbId(db *gorm.DB, userId uint, tmdbId uint) (Watched, error) {
|
||||
slog.Debug("getWatchedItemByIgdbId: Running.", "userId", userId, "tmdbId", tmdbId)
|
||||
watched := new(Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("Tags").
|
||||
Where("user_id = ? AND Game.igdb_id = ?", userId, tmdbId).
|
||||
Take(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemByIgdbId: Failed!", "error", res.Error)
|
||||
return Watched{}, res.Error
|
||||
}
|
||||
slog.Debug("getWatchedItemByIgdbId: Done.", "userId", userId, "tmdbId", tmdbId, "watched_item", watched)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Same as `getWatchedItemByIgdbId` except for getting in bulk (multiple content ids).
|
||||
// `c` should be a slice of igdb ids.
|
||||
func getWatchedItemsByIgdbIds(db *gorm.DB, userId uint, c []int) ([]Watched, error) {
|
||||
slog.Debug("getWatchedItemsByIgdbIds: Running.", "userId", userId, "c", c)
|
||||
watched := new([]Watched)
|
||||
res := db.Model(&Watched{}).
|
||||
Joins("Game").
|
||||
Preload("Game.Poster").
|
||||
Preload("Activity").
|
||||
Preload("Tags").
|
||||
Where("user_id = ?", userId).
|
||||
Where("(Game.igdb_id) IN ?", c).
|
||||
Find(&watched)
|
||||
if res.Error != nil {
|
||||
slog.Error("getWatchedItemsByIgdbIds: Failed!", "error", res.Error)
|
||||
return []Watched{}, res.Error
|
||||
}
|
||||
slog.Debug(
|
||||
"getWatchedItemsByIgdbIds: Done.",
|
||||
"userId", userId,
|
||||
"watcheds_found", len(*watched),
|
||||
// "wdev", *watched,
|
||||
)
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
// Get another users **public** watchlist.
|
||||
func getPublicWatched(db *gorm.DB, userId uint, username string) ([]Watched, error) {
|
||||
slog.Debug("getPublicWatched running", "user_id", userId, "username", username)
|
||||
// First we need to make sure the users list is public
|
||||
user := new(User)
|
||||
// Figure we require knowlege of the users id and name to make it
|
||||
// harder to just type in random ids and see someones list.. dunno
|
||||
// if this is a thing we need but its here.. for now at least.
|
||||
res := db.Where("id = ? AND username = ?", userId, username).Take(&user)
|
||||
if res.Error != nil {
|
||||
slog.Error("Failed to get user for getPublicWatched request", "user_id", userId)
|
||||
return []Watched{}, errors.New("failed to check privacy settings")
|
||||
}
|
||||
if user.Private != nil && *user.Private {
|
||||
slog.Error("getPublicWatched attempted to get a private list", "user_id", userId)
|
||||
return []Watched{}, errors.New("this watched list is private")
|
||||
}
|
||||
// Now we know the user is public, return their list
|
||||
watched := new([]Watched)
|
||||
res = db.Model(&Watched{}).Preload("Content").Preload("Game").Preload("Game.Poster").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
|
||||
if res.Error != nil {
|
||||
panic(res.Error)
|
||||
}
|
||||
return *watched, nil
|
||||
}
|
||||
|
||||
func addWatched(db *gorm.DB, userId uint, ar WatchedAddRequest, at ActivityType) (Watched, error) {
|
||||
slog.Debug("Adding watched item", "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
// Get content cache (or cache it if we don't have it locally)
|
||||
content, err := getOrCacheContent(db, ar.ContentType, ar.ContentID)
|
||||
if err != nil {
|
||||
return Watched{}, err
|
||||
}
|
||||
// Error if content has no id
|
||||
if content.ID == 0 {
|
||||
return Watched{}, errors.New("failed to find content id")
|
||||
}
|
||||
// Create watched entry in db
|
||||
if ar.Status == "" {
|
||||
// Set default status for when content is added by
|
||||
// rating it instead of giving status first.
|
||||
if ar.ContentType == "movie" {
|
||||
ar.Status = FINISHED
|
||||
} else {
|
||||
ar.Status = WATCHING
|
||||
}
|
||||
}
|
||||
watched := Watched{Status: ar.Status, Rating: ar.Rating, UserID: userId, ContentID: &content.ID}
|
||||
if ar.Thoughts != "" {
|
||||
watched.Thoughts = ar.Thoughts
|
||||
}
|
||||
// If custom WatchedDate passed, set CreatedAt and UpdatedAt fields to it.
|
||||
if !ar.WatchedDate.IsZero() {
|
||||
slog.Debug("Adding watched item: The provided WatchedDate is valid.", "watched_date", ar.WatchedDate, "userId", userId, "contentType", ar.ContentType, "contentId", ar.ContentID)
|
||||
watched.CreatedAt = ar.WatchedDate
|
||||
watched.UpdatedAt = ar.WatchedDate
|
||||
}
|
||||
res := db.Create(&watched)
|
||||
if res.Error != nil {
|
||||
if res.Error == gorm.ErrDuplicatedKey {
|
||||
res = db.Model(&Watched{}).Unscoped().Preload("Activity").Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Take(&watched)
|
||||
if res.Error != nil {
|
||||
return Watched{}, errors.New("content already on watched list. errored checking for soft deleted record")
|
||||
}
|
||||
if watched.DeletedAt.Time.IsZero() {
|
||||
return watched, errors.New("content already on watched list")
|
||||
} else {
|
||||
slog.Info("addWatched: Watched list item for this content exists as soft deleted record.. attempting to restore")
|
||||
res = db.Model(&Watched{}).Unscoped().Where("user_id = ? AND content_id = ?", userId, watched.ContentID).Updates(map[string]interface{}{"status": ar.Status, "rating": ar.Rating, "deleted_at": nil})
|
||||
watched.Status = ar.Status
|
||||
watched.Rating = ar.Rating
|
||||
watched.Thoughts = ar.Thoughts
|
||||
if res.Error != nil {
|
||||
slog.Error("addWatched: Failed to restore soft deleted watch list item", "error", res.Error)
|
||||
return Watched{}, errors.New("content already on watched list. errored removing soft delete timestamp")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("Error adding watched content to database", "error", res.Error.Error())
|
||||
return Watched{}, errors.New("failed adding content to database")
|
||||
}
|
||||
}
|
||||
slog.Debug("Added watched list item", "item", watched)
|
||||
|
||||
var activity Activity
|
||||
activityJson, err := json.Marshal(map[string]interface{}{"status": ar.Status, "rating": ar.Rating})
|
||||
if err != nil {
|
||||
slog.Error("Failed to marshal json for data in ADD_WATCHED activity request, adding without data", "error", err.Error())
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at})
|
||||
} else {
|
||||
activity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: watched.ID, Type: at, Data: string(activityJson)})
|
||||
}
|
||||
watched.Activity = append(watched.Activity, activity)
|
||||
watched.Content = &content
|
||||
return watched, nil
|
||||
}
|
||||
|
||||
// this method is too ugly to look at please make him look better, future irhm
|
||||
func updateWatched(db *gorm.DB, userId uint, id uint, ar WatchedUpdateRequest) (WatchedUpdateResponse, error) {
|
||||
slog.Debug("UpdateWatched", "request_data", ar)
|
||||
upwat := Watched{}
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Take(&upwat)
|
||||
if res.Error != nil {
|
||||
slog.Error("Watched entry update failed:", "id", id, "error", res.Error.Error())
|
||||
return WatchedUpdateResponse{}, errors.New("failed to update watched entry")
|
||||
}
|
||||
originalThoughts := upwat.Thoughts
|
||||
if ar.Rating != 0 {
|
||||
upwat.Rating = ar.Rating
|
||||
}
|
||||
if ar.Status != "" {
|
||||
upwat.Status = ar.Status
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
upwat.Thoughts = ar.Thoughts
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
upwat.Thoughts = ""
|
||||
}
|
||||
if ar.Pinned != nil {
|
||||
upwat.Pinned = *ar.Pinned
|
||||
}
|
||||
res = db.Save(upwat)
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedUpdateResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity := Activity{}
|
||||
if ar.Rating != 0 {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: RATING_CHANGED, Data: strconv.Itoa(int(ar.Rating))})
|
||||
}
|
||||
if ar.Status != "" {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: STATUS_CHANGED, Data: string(ar.Status)})
|
||||
}
|
||||
if ar.Thoughts != "" {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: THOUGHTS_CHANGED})
|
||||
}
|
||||
if ar.RemoveThoughts {
|
||||
addedActivity, _ = addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: THOUGHTS_REMOVED, Data: originalThoughts})
|
||||
}
|
||||
return WatchedUpdateResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
func updateWatchedLastViewedSeason(db *gorm.DB, userId uint, id uint, seasonNum int) error {
|
||||
slog.Debug("UpdateWatchedLastViewedSeason", "user_id", userId, "id", id, "season_num", seasonNum)
|
||||
res := db.
|
||||
Model(&Watched{}).
|
||||
Where("id = ? AND user_id = ?", id, userId).
|
||||
Update("last_viewed_season", seasonNum)
|
||||
if res.Error != nil {
|
||||
slog.Error("updateWatchedLastViewedSeason: Failed when updating.", "error", res.Error)
|
||||
return errors.New("failed to update db")
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
// likely the watched entry does not exist or is not owned by this `userId`.
|
||||
slog.Error("updateWatchedLastViewedSeason: Watched entry does not exist.")
|
||||
return errors.New("watched entry does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeWatched(db *gorm.DB, userId uint, id uint) (WatchedRemoveResponse, error) {
|
||||
slog.Debug("Removing watched item:", "id", id, "user_id", userId)
|
||||
// Our model has a deleted_at field, which will make gorm do a soft delete.
|
||||
// Since other tables (eg activities) will link their rows to a watched_id, it's best to soft
|
||||
// delete, so if user restores watched item they still have activity for example (also so
|
||||
// someone else wont get other users activity if auto increment gives them the same watched id).
|
||||
res := db.Model(&Watched{}).Where("id = ? AND user_id = ?", id, userId).Delete(&Watched{})
|
||||
if res.Error != nil {
|
||||
slog.Error("Removing watched entry failed", "id", id, "error", res.Error.Error())
|
||||
return WatchedRemoveResponse{}, errors.New("failed to remove watched entry")
|
||||
}
|
||||
if res.RowsAffected <= 0 {
|
||||
return WatchedRemoveResponse{}, errors.New("no watched entry found")
|
||||
}
|
||||
addedActivity, _ := addActivity(db, userId, ActivityAddRequest{WatchedID: id, Type: REMOVED_WATCHED})
|
||||
return WatchedRemoveResponse{NewActivity: addedActivity}, nil
|
||||
}
|
||||
|
||||
// Download file over http (used for downloading poster images)
|
||||
// url - The remote file url.
|
||||
// outf - Where should we store the downloaded file.
|
||||
// force - Should we overwrite an existing file? If false, existing files will be skipped.
|
||||
func download(url string, outf string, force bool) (err error) {
|
||||
slog.Debug("download: Attempting to download file", "url", url, "outf", outf, "force", force)
|
||||
// If not forced, skip call if file already exists to save unnecessary requests.
|
||||
if !force {
|
||||
if _, err := os.Stat(outf); !errors.Is(err, os.ErrNotExist) {
|
||||
slog.Debug("download: Skipping file, it already exists locally.", "outf", outf, "error", err)
|
||||
return nil
|
||||
} else {
|
||||
slog.Debug("download: Continuing to download file, it does not already exist.", "outf", outf, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the data
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to make request.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
slog.Error("download: Request failed. Non OK response.", "outf", outf, "status", resp.Status, "error", err)
|
||||
return fmt.Errorf("bad status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create the file
|
||||
out, err := os.Create(outf)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
slog.Warn("download: Failed to create out file, trying to recover by ensuring directories exist.", "outf", outf)
|
||||
err = os.MkdirAll(path.Dir(outf), 0764)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create dir(s) in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
// If dirs made, try making file again
|
||||
out, err = os.Create(outf)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to create out file again in recovery attempt.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
slog.Info("download: recovered by creating dir(s).", "outf", outf)
|
||||
} else {
|
||||
slog.Error("download: Failed to create out file. No known recovery path possible.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
// Write the body to file
|
||||
_, err = io.Copy(out, resp.Body)
|
||||
if err != nil {
|
||||
slog.Error("download: Failed to write file to our file.", "outf", outf, "error", err)
|
||||
return err
|
||||
}
|
||||
|
||||
slog.Debug("download: Successfully downloaded file", "outf", outf)
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user