(wip) server restructure/refactor

This commit is contained in:
IRHM
2025-10-24 18:37:20 +01:00
parent 61fa88167c
commit 8f2a76e9a8
102 changed files with 6795 additions and 5449 deletions
+1 -2
View File
@@ -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",
+2
View File
@@ -0,0 +1,2 @@
run:
MODE=DEV go run .
-130
View File
@@ -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
View File
@@ -1,4 +1,4 @@
package main
package cache
import (
"log/slog"
+34
View File
@@ -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
}
+111 -163
View File
@@ -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
}
-556
View File
@@ -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
}
-354
View File
@@ -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
}
+42
View File
@@ -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
}
+14
View File
@@ -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"`
}
+57
View File
@@ -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"`
}
+40
View File
@@ -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"`
}
+77
View File
@@ -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
}
+35
View File
@@ -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"`
}
+13
View File
@@ -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:"-"`
}
+27
View File
@@ -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"`
}
+15
View File
@@ -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"`
}
+17
View File
@@ -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;"`
}
+17
View File
@@ -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"`
}
+84
View File
@@ -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"`
}
+36
View File
@@ -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"`
}
+19
View File
@@ -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"`
}
+14
View File
@@ -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"`
}
+1
View File
@@ -0,0 +1 @@
package domain
+81
View File
@@ -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
}
+96
View File
@@ -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)
}
+45 -74
View File
@@ -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")
+443
View File
@@ -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)
}
+112 -300
View File
@@ -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
}
+220
View File
@@ -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()})
}
+1
View File
@@ -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)
+613
View File
@@ -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
// }
+380
View File
@@ -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)
}
+42
View File
@@ -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
}
+30
View File
@@ -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()
+94
View File
@@ -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
}
+60
View File
@@ -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
}
+136
View File
@@ -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,
+60
View File
@@ -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,
+72
View File
@@ -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)
}
+16 -14
View File
@@ -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
+36
View File
@@ -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)
}
+32 -56
View File
@@ -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)
}
}
+42
View File
@@ -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
}
+35
View File
@@ -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)
}
+169
View File
@@ -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()})
}
+90
View File
@@ -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
}
+63
View File
@@ -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
+113
View File
@@ -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)
}
+115
View File
@@ -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
}
+48
View File
@@ -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()})
}
+22 -18
View File
@@ -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")
}
+136
View File
@@ -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)
}
+31 -47
View File
@@ -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
}
+331
View File
@@ -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
}
+63
View File
@@ -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)
}
+190
View File
@@ -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)
}
+63
View File
@@ -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
}
+423
View File
@@ -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"
+76
View File
@@ -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
}
-60
View File
@@ -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
View File
@@ -1,6 +1,6 @@
module github.com/sbondCo/Watcharr
go 1.24
go 1.25
require (
github.com/buckket/go-blurhash v1.1.0
+38
View File
@@ -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"
+31 -76
View File
@@ -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
}
+81
View File
@@ -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,
})
+46
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
-194
View File
@@ -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
}
+37
View File
@@ -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)
}
}
-49
View File
@@ -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)
}
}
+2 -2
View File
@@ -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"
+11
View File
@@ -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
View File
@@ -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")
-517
View File
@@ -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