diff --git a/package-lock.json b/package-lock.json index 7976e47f..9b8b7859 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "axios": "^1.9.0", "blurhash": "^2.0.5", + "dayjs": "^1.11.13", "papaparse": "^5.4.1" }, "devDependencies": { @@ -3961,6 +3962,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", diff --git a/package.json b/package.json index 9c7f9ffd..07c874ec 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "dependencies": { "axios": "^1.9.0", "blurhash": "^2.0.5", + "dayjs": "^1.11.13", "papaparse": "^5.4.1" } } diff --git a/server/api_key.go b/server/api_key.go new file mode 100644 index 00000000..b8d702ad --- /dev/null +++ b/server/api_key.go @@ -0,0 +1,127 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "log/slog" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "gorm.io/gorm" +) + +// APIKey represents a user-generated programmatic access key. +// Keys are stored hashed to avoid leaking plaintext if DB is exposed. +// We store the plaintext only once on creation and return it to caller. +// +// NOTE: The hash is a simple SHA-256 of the key. For Bearer-style secrets +// this is sufficient. +// +// A unique index on KeyHash prevents duplicates. +type APIKey struct { + ID uint `gorm:"primarykey" json:"id"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + LastUsed time.Time `json:"lastUsed"` + // Hex-encoded SHA-256 of the key + KeyHash string `gorm:"uniqueIndex;not null" json:"-"` + UserID uint `gorm:"not null" json:"userId"` + Revoked bool `gorm:"default:false" json:"revoked"` +} + +// createAPIKey generates and stores a new key for the given user and returns the +// plaintext (only shown once!). +func createAPIKey(db *gorm.DB, userId uint) (string, error) { + // Generate 32-byte random key (URL-safe base64 length ~43 chars) + raw, err := generateString(32) + if err != nil { + return "", err + } + keyHash := sha256Hex(raw) + + if err := db.Create(&APIKey{KeyHash: keyHash, UserID: userId}).Error; err != nil { + slog.Error("createAPIKey: db insert failed", "error", err) + return "", errors.New("failed to create key") + } + return raw, nil +} + +func getAPIKeys(db *gorm.DB, userId uint) ([]APIKey, error) { + var keys []APIKey + if err := db.Where("user_id = ?", userId).Find(&keys).Error; err != nil { + slog.Error("getAPIKeys: query failed", "error", err) + return nil, errors.New("failed to fetch keys") + } + return keys, nil +} + +func revokeAPIKey(db *gorm.DB, userId, id uint) error { + res := db.Model(&APIKey{}).Where("id = ? AND user_id = ?", id, userId).Update("revoked", true) + if res.Error != nil { + slog.Error("revokeAPIKey: update failed", "error", res.Error) + return errors.New("failed to revoke key") + } + if res.RowsAffected == 0 { + return errors.New("key not found") + } + return nil +} + +// sha256Hex returns lowercase hex SHA-256 digest of input. +func sha256Hex(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +// validateAPIKey checks query param/ header key and, if valid, returns the owning user id. +// validateAPIKey checks db for the given plaintext key and returns its user id. +func validateAPIKey(db *gorm.DB, plaintext string) (uint, error) { + var k APIKey + h := sha256Hex(plaintext) + res := db.Where("key_hash = ? AND revoked = 0", h).Take(&k) + if res.Error != nil { + return 0, errors.New("invalid api key") + } + // touch last_used + db.Model(&APIKey{}).Where("id = ?", k.ID).Update("last_used", time.Now()) + return k.UserID, nil +} + +// AuthOrAPIKey authenticates via JWT (Authorization header) or api_key query/header. +// decided to write this as an either/or scenario to avoid rewriting all endpoints, this is pretty standard though to use an API key for programmatic access with +// basically the same logic as JWT but with a different secret +func AuthOrAPIKey(db *gorm.DB) gin.HandlerFunc { + return func(c *gin.Context) { + // Try JWT first + if tok := c.GetHeader("Authorization"); tok != "" { + t, err := jwt.ParseWithClaims(tok, &TokenClaims{}, func(token *jwt.Token) (interface{}, error) { + return []byte(Config.JWT_SECRET), nil + }) + if err == nil { + if claims, ok := t.Claims.(*TokenClaims); ok && t.Valid { + c.Set("userId", claims.UserID) + c.Next() + return + } + } + } + // Fallback to api key + k := c.Query("api_key") + if k == "" { + k = c.GetHeader("X-Api-Key") + } + if k == "" { + c.AbortWithStatus(401) + return + } + uid, err := validateAPIKey(db, k) + if err != nil { + c.AbortWithStatus(401) + return + } + c.Set("userId", uid) + c.Next() + } +} diff --git a/server/api_key_routes.go b/server/api_key_routes.go new file mode 100644 index 00000000..c21e6311 --- /dev/null +++ b/server/api_key_routes.go @@ -0,0 +1,50 @@ +package main + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// addAPIKeyRoutes registers /api_keys CRUD operations. Requires normal JWT auth. +func (b *BaseRouter) addAPIKeyRoutes() { + api := b.rg.Group("/api_keys").Use(AuthRequired(b.db)) + + // List keys + api.GET("", func(c *gin.Context) { + userId := c.MustGet("userId").(uint) + keys, err := getAPIKeys(b.db, userId) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, keys) + }) + + // Create new key (returns plaintext once) + api.POST("", func(c *gin.Context) { + userId := c.MustGet("userId").(uint) + key, err := createAPIKey(b.db, userId) + if err != nil { + c.JSON(http.StatusInternalServerError, ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"apiKey": key}) + }) + + // Revoke (delete) key + api.DELETE(":id", func(c *gin.Context) { + userId := c.MustGet("userId").(uint) + id, err := strconv.Atoi(c.Param("id")) + if err != nil { + c.Status(http.StatusBadRequest) + return + } + if err := revokeAPIKey(b.db, userId, uint(id)); err != nil { + c.JSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + return + } + c.Status(http.StatusOK) + }) +} diff --git a/server/config.go b/server/config.go index 7407a8fd..30302e2a 100644 --- a/server/config.go +++ b/server/config.go @@ -8,6 +8,7 @@ import ( "os" "path" "time" + "strconv" "github.com/sbondCo/Watcharr/game" "gorm.io/gorm" @@ -45,6 +46,20 @@ type ServerConfig struct { // If unprovided, the default Watcharr API key will be used. TMDB_KEY string `json:",omitempty"` + // Optional: Provide your own TVDB API Key to use the emby auto-scrobbling feature + // this is REQUIRED if you want emby to automatically send data to your watcharr instance as emby + // uses tvdb as its provider for episode numbering and ids, so without this watcharr will not be able to + // correctly identify the episode number and absolute number that emby is sending to watcharr. + TVDB_KEY string `json:",omitempty"` + + // Default rating (0-10) assigned to episodes automatically imported via + // Emby webhook. Can be changed via the server settings UI. + EMBY_DEFAULT_RATING int `json:",omitempty"` + + // Global API rate-limit in requests per second used for external APIs such + // as TMDB / TVDB when processing Emby webhooks. 0 or unset falls back to 10. + GLOBAL_RATE_LIMIT_RPS int `json:",omitempty"` + // Optional: Point to Plex install to enable plex features. PLEX_HOST string `json:",omitempty"` @@ -85,6 +100,9 @@ func (c *ServerConfig) GetSafe() ServerConfig { JELLYFIN_HOST: c.JELLYFIN_HOST, USE_EMBY: c.USE_EMBY, TMDB_KEY: c.TMDB_KEY, + TVDB_KEY: c.TVDB_KEY, + EMBY_DEFAULT_RATING: c.EMBY_DEFAULT_RATING, + GLOBAL_RATE_LIMIT_RPS: c.GLOBAL_RATE_LIMIT_RPS, PLEX_HOST: c.PLEX_HOST, PLEX_MACHINE_ID: c.PLEX_MACHINE_ID, DEBUG: c.DEBUG, @@ -114,6 +132,8 @@ func (c *ServerConfig) Get(s string) (ServerConfigGetByName, error) { return ServerConfigGetByName{Value: c.SIGNUP_ENABLED}, nil case "TMDB_KEY": return ServerConfigGetByName{Value: c.TMDB_KEY}, nil + case "TVDB_KEY": + return ServerConfigGetByName{Value: c.TVDB_KEY}, nil case "PLEX_HOST": return ServerConfigGetByName{Value: c.PLEX_HOST}, nil case "PLEX_MACHINE_ID": @@ -121,7 +141,11 @@ func (c *ServerConfig) Get(s string) (ServerConfigGetByName, error) { case "HEADER_AUTH": return ServerConfigGetByName{Value: c.HEADER_AUTH}, nil case "DEBUG": - return ServerConfigGetByName{Value: c.DEBUG}, nil + return ServerConfigGetByName{Value: true}, nil + case "EMBY_DEFAULT_RATING": + return ServerConfigGetByName{Value: c.EMBY_DEFAULT_RATING}, nil + case "GLOBAL_RATE_LIMIT_RPS": + return ServerConfigGetByName{Value: c.GLOBAL_RATE_LIMIT_RPS}, nil } return ServerConfigGetByName{}, errors.New("invalid setting") } @@ -172,6 +196,8 @@ func generateConfig() error { JWT_SECRET: key, // Other defaults.. DEFAULT_COUNTRY: "US", + EMBY_DEFAULT_RATING: 5, + GLOBAL_RATE_LIMIT_RPS: 10, SIGNUP_ENABLED: true, } barej, err := json.MarshalIndent(cfg, "", "\t") @@ -196,11 +222,17 @@ func updateConfig(k string, v any) error { Config.SIGNUP_ENABLED = v.(bool) } else if k == "TMDB_KEY" { Config.TMDB_KEY = v.(string) + } else if k == "TVDB_KEY" { + Config.TVDB_KEY = v.(string) } else if k == "DEBUG" { Config.DEBUG = v.(bool) setLoggingLevel() } else if k == "DEFAULT_COUNTRY" { Config.DEFAULT_COUNTRY = v.(string) + } else if k == "EMBY_DEFAULT_RATING" { + Config.EMBY_DEFAULT_RATING = toInt(v) + } else if k == "GLOBAL_RATE_LIMIT_RPS" { + Config.GLOBAL_RATE_LIMIT_RPS = toInt(v) } else { return errors.New("invalid setting") } @@ -221,6 +253,25 @@ func writeConfig() error { return os.WriteFile(path.Join(DataPath, "watcharr.json"), barej, 0755) } +// toInt safely converts common JSON number representations to int. +func toInt(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case float32: + return int(t) + case int: + return t + case int64: + return int(t) + case string: + if i, err := strconv.Atoi(t); err == nil { + return i + } + } + return 0 +} + type ServerFeatures struct { Sonarr bool `json:"sonarr"` Radarr bool `json:"radarr"` diff --git a/server/emby_rate.go b/server/emby_rate.go new file mode 100644 index 00000000..b42772b2 --- /dev/null +++ b/server/emby_rate.go @@ -0,0 +1,6 @@ +package main + +// embyLimiter restricts Emby webhook processing using GLOBAL_RATE_LIMIT_RPS (default 10 req/s) +// added because if the user marks an entire show as played that can have like 1k+ episodes, it would be a lot of consecutive requests +// and if you burst above 40ish requests per second you're likely to get banned/throttled from tvdb/tmdb api. +var embyLimiter = newRateLimiterFromConfig(10) diff --git a/server/episode_translate.go b/server/episode_translate.go new file mode 100644 index 00000000..b0fa36f9 --- /dev/null +++ b/server/episode_translate.go @@ -0,0 +1,52 @@ +package main + +import ( + "errors" + "sort" + "strconv" +) + +// probably the most difficult part to solve for automatic mapping of episodes from tvdb format to tmdb format, this implementation is based on filebot sonnarr/radarr basically, and it uses +// the absolute number of the episode to map it to the correct season/episode in tmdb when tvdb disagrees about season labeling for whatever reason for live tv shows, anime, reality tv, etc. +// MapTVDBEpisodeToTMDB converts a TVDB episode id into the corresponding +// TMDB show-id, season and episode numbers using the simplified 3-request +// algorithm of get tvdb series id from episode id, find tmdb show id from tvdb +// series id, and then get tmdb show details to know season layout and mapping between episodes and absolute numbers. +// this prevents errors when translating episodes from emby's tvdb format to watcharr's tmdb format. +func MapTVDBEpisodeToTMDB(tvdbEpID int) (tmdbShowID int, tmdbSeason int, tmdbEpisode int, err error) { + // 1. TVDB episode → (seriesID, absoluteNumber) + seriesID, absNum, err := getTVDBEpisodeInfo(tvdbEpID) + if err != nil { + return 0, 0, 0, err + } + + // 2. TVDB series → TMDB show id via /find + tmdbShowID, err = findTMDBShowIDByTVDBSeriesID(seriesID) + if err != nil { + return 0, 0, 0, err + } + + // 3. Grab TMDB show details to know season layout + var details TMDBShowDetails + if err = tmdbRequest("/tv/"+strconv.Itoa(tmdbShowID), nil, &details); err != nil { + return 0, 0, 0, err + } + + if uint32(absNum) > details.NumberOfEpisodes { + return 0, 0, 0, errors.New("tvdb absoluteNumber exceeds tmdb total episodes") + } + + // Ensure seasons sorted ascending. + seasons := details.Seasons + sort.Slice(seasons, func(i, j int) bool { return seasons[i].SeasonNumber < seasons[j].SeasonNumber }) + + remaining := absNum + for _, s := range seasons { + if remaining <= s.EpisodeCount { + return tmdbShowID, s.SeasonNumber, remaining, nil + } + remaining -= s.EpisodeCount + } + + return 0, 0, 0, errors.New("failed to map absoluteNumber to tmdb season/episode") +} diff --git a/server/go.mod b/server/go.mod index 77b668c5..00a11440 100644 --- a/server/go.mod +++ b/server/go.mod @@ -11,6 +11,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.38.0 + golang.org/x/time v0.12.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 diff --git a/server/go.sum b/server/go.sum index cc06c4b4..de3c2166 100644 --- a/server/go.sum +++ b/server/go.sum @@ -114,6 +114,8 @@ golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/server/helpers.go b/server/helpers.go index 1cde684c..16b8f626 100644 --- a/server/helpers.go +++ b/server/helpers.go @@ -5,12 +5,14 @@ import ( b64 "encoding/base64" ) -// Generate a random string +// Generate a random string, changed to safe64 encoding to be used in urls, this shouldn't affect anything program wide. +// if anything it limits the pool of characters to choose from for the JWT signing secret, however looking over where this is used elsewhere, it still gives +// you extremely high entropy secrets that are extremely safe to use. func generateString(len int) (string, error) { key := make([]byte, len) _, err := rand.Read(key) if err != nil { return "", err } - return b64.StdEncoding.EncodeToString([]byte(key)), nil + return b64.RawURLEncoding.EncodeToString(key), nil } diff --git a/server/limiter.go b/server/limiter.go new file mode 100644 index 00000000..268ca7b0 --- /dev/null +++ b/server/limiter.go @@ -0,0 +1,13 @@ +package main + +import "golang.org/x/time/rate" + +// newRateLimiterFromConfig returns a limiter using GLOBAL_RATE_LIMIT_RPS from +// server config with the provided default fallback of 10 req/s +func newRateLimiterFromConfig(defaultR int) *rate.Limiter { + r := defaultR + if Config.GLOBAL_RATE_LIMIT_RPS > 0 { + r = Config.GLOBAL_RATE_LIMIT_RPS + } + return rate.NewLimiter(rate.Limit(r), r) +} diff --git a/server/routes.go b/server/routes.go index 4f409337..6e75ce65 100644 --- a/server/routes.go +++ b/server/routes.go @@ -585,11 +585,15 @@ func (b *BaseRouter) addWatchedRoutes() { c.JSON(http.StatusOK, response) }) + // Add /watched/episode + // minor changes because + // Instead of echoing err.Error() (which can leak internal validation details), it now returns a consistent "invalid request body" string. + // could refactor other routes for more specific error handling but just did it for this one while i was testing my PR for more info. + watched.POST("/episode", func(c *gin.Context) { userId := c.MustGet("userId").(uint) var ar WatchedEpisodeAddRequest - err := c.ShouldBindJSON(&ar) - if err == nil { + if err := c.ShouldBindJSON(&ar); err == nil { response, err := addWatchedEpisodes(b.db, userId, ar) if err != nil { c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()}) @@ -598,7 +602,7 @@ func (b *BaseRouter) addWatchedRoutes() { c.JSON(http.StatusOK, response) return } - c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: err.Error()}) + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: "invalid request body"}) }) watched.DELETE("/episode/:id", func(c *gin.Context) { diff --git a/server/tmdb.go b/server/tmdb.go index e32105f1..925bb873 100644 --- a/server/tmdb.go +++ b/server/tmdb.go @@ -8,6 +8,9 @@ import ( "net/http" "net/url" "time" + "strings" + "path" + "strconv" ) type TMDBSearchResponse[R any] struct { @@ -605,6 +608,7 @@ func getTMDBKey() string { if Config.TMDB_KEY != "" { return Config.TMDB_KEY } + slog.Debug("Using built-in TMDB fallback key; set TMDB_KEY in config for higher rate-limits and reliability") return "d047fa61d926371f277e7a83c9c4ff2c" } @@ -615,12 +619,20 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) { return nil, errors.New("failed to parse api uri") } - // Path params - base.Path += ep + // Ensure we have exactly one slash between base path and endpoint + if !strings.HasPrefix(ep, "/") { + ep = "/" + ep + } + base.Path = path.Join(base.Path, ep) // Query params params := url.Values{} - params.Add("api_key", getTMDBKey()) + key := getTMDBKey() + isBearer := strings.Contains(key, ".") && strings.HasPrefix(key, "ey") + slog.Info("TMDB key in use", "bearer", isBearer) + if !isBearer { + params.Add("api_key", key) + } params.Add("language", "en-US") for k, v := range p { params.Add(k, v) @@ -629,8 +641,23 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) { // Add params to url base.RawQuery = params.Encode() - // Run get request - res, err := http.Get(base.String()) + // Log full request URL (without api_key for security) + safeURL := base + q := safeURL.Query() + q.Del("api_key") + safeURL.RawQuery = q.Encode() + slog.Debug("TMDB request", "url", safeURL.String()) + + // Build request + req, err := http.NewRequest(http.MethodGet, base.String(), nil) + if err != nil { + return nil, err + } + if isBearer { + req.Header.Set("Authorization", "Bearer "+key) + } + client := &http.Client{Timeout: 15 * time.Second} + res, err := client.Do(req) if err != nil { return nil, err } @@ -640,12 +667,27 @@ func tmdbAPIRequest(ep string, p map[string]string) ([]byte, error) { return nil, err } if res.StatusCode != 200 { - slog.Error("TMDB non 200 status code:", "status_code", res.StatusCode) + slog.Error("TMDB API error", "status_code", res.StatusCode, "url", safeURL.String(), "resp", string(body)) return nil, errors.New(string(body)) } return body, nil } +func findTMDBShowIDByTVDBSeriesID(seriesID int) (int, error) { + type findResp struct { + TvResults []struct{ ID int `json:"id"` } `json:"tv_results"` + } + var fr findResp + ep := "find/" + strconv.Itoa(seriesID) + if err := tmdbRequest(ep, map[string]string{"external_source": "tvdb_id"}, &fr); err != nil { + return 0, err + } + if len(fr.TvResults) == 0 { + return 0, errors.New("tmdb find returned no tv_results for series id") + } + return fr.TvResults[0].ID, nil +} + func tmdbRequest(ep string, p map[string]string, resp interface{}) error { body, err := tmdbAPIRequest(ep, p) if err != nil { diff --git a/server/tvdb.go b/server/tvdb.go new file mode 100644 index 00000000..d31ef534 --- /dev/null +++ b/server/tvdb.go @@ -0,0 +1,135 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "strconv" + "sync" + "time" +) + + +const tvdbBaseURL = "https://api4.thetvdb.com/v4" + +var ( + tvdbToken string + tvdbTokenExp time.Time + tvdbTokenMu sync.Mutex +) + +type tvdbLoginResp struct { + Data struct { + Token string `json:"token"` + Expires string `json:"expires"` + } `json:"data"` +} + +// getTVDBToken ensures we have a valid bearer token (token lasts 30days). +func getTVDBToken() (string, error) { + tvdbTokenMu.Lock() + defer tvdbTokenMu.Unlock() + + if tvdbToken != "" && time.Now().Before(tvdbTokenExp.Add(-time.Minute)) { + return tvdbToken, nil + } + + key := Config.TVDB_KEY + if key == "" { + slog.Error("TVDB_KEY not set in config; requests will fail") + return "", errors.New("TVDB_KEY not configured") + } + body, _ := json.Marshal(map[string]string{"apikey": key}) + req, _ := http.NewRequest("POST", tvdbBaseURL+"/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return "", errors.New("tvdb login failed: " + resp.Status) + } + var lr tvdbLoginResp + if err := json.NewDecoder(resp.Body).Decode(&lr); err != nil { + return "", err + } + tvdbToken = lr.Data.Token + tvdbTokenExp = time.Now().Add(23 * time.Hour) // safe margin + return tvdbToken, nil +} + +func tvdbRequest(endpoint string, v interface{}) error { + for attempt := 0; attempt < 2; attempt++ { + token, err := getTVDBToken() + if err != nil { + slog.Error("tvdbRequest: token err", "err", err) + return err + } + + req, _ := http.NewRequest("GET", tvdbBaseURL+endpoint, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + + // Handle auth failures – invalidate cached token once and retry, i believe tvdb uses the status code 401/403 to indicate auth failure, i haven't waited a month to confirm this though + // if users report issues with tvdb auth expiries, we can make simple changes to tweak this. + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + resp.Body.Close() + tvdbTokenMu.Lock() + tvdbToken = "" + tvdbTokenExp = time.Time{} + tvdbTokenMu.Unlock() + if attempt == 0 { + // retry once with fresh token + continue + } + } + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return errors.New("tvdb api: " + resp.Status + " " + string(body)) + } + err = json.NewDecoder(resp.Body).Decode(&v) + resp.Body.Close() + return err + } + return errors.New("tvdb api: exhausted retries") +} + +// getTVDBEpisodeInfo fetches /episodes/{id}/extended and returns the TVDB +// series id and absolute episode number. +func getTVDBEpisodeInfo(epId int) (seriesId int, absoluteNumber int, err error) { + var resp struct { + Data struct { + SeriesID int `json:"seriesId"` + AbsoluteNumber int `json:"absoluteNumber"` + } `json:"data"` + } + if err = tvdbRequest("/episodes/"+strconv.Itoa(epId)+"/extended", &resp); err != nil { + return 0, 0, err + } + return resp.Data.SeriesID, resp.Data.AbsoluteNumber, nil +} +// The v4 episode payload now includes the seriesId directly on the root data +// object, so we extract it from there instead of the nested series struct. + +func getTVDBSeriesIDFromEpisode(epId int) (int, error) { + var resp struct { + Data struct { + SeriesID int `json:"seriesId"` + } `json:"data"` + } + if err := tvdbRequest("/episodes/"+strconv.Itoa(epId), &resp); err != nil { + return 0, err + } + return resp.Data.SeriesID, nil +} + diff --git a/server/watcharr.go b/server/watcharr.go index 16480218..21c47e5d 100644 --- a/server/watcharr.go +++ b/server/watcharr.go @@ -82,6 +82,7 @@ func main() { &Image{}, &Game{}, &ArrRequest{}, + &APIKey{}, &Tag{}, ) if err != nil { @@ -148,6 +149,8 @@ func main() { br.addProfileRoutes() br.addJellyfinRoutes() br.addPlexRoutes() + br.addAPIKeyRoutes() + br.addWatchedShowRoute() br.addUserRoutes() br.addFollowRoutes() br.addImportRoutes() diff --git a/server/watched_show.go b/server/watched_show.go new file mode 100644 index 00000000..22a2e97e --- /dev/null +++ b/server/watched_show.go @@ -0,0 +1,215 @@ +package main + +import ( + "errors" + "log/slog" + "strconv" + "time" + + "gorm.io/gorm" +) + +// WatchedShowAddRequest is used by the /watched/show endpoint to add (and optionally +// create) a show together with one of its episodes. +// +// Either TMDBID (preferred) or TMDBEpisodeID MUST be provided. SeasonNumber and +// EpisodeNumber are always required. +// All rating fields are expected to be 0–10 (inclusive). Status values must be one +// of the WatchedStatus enum constants. +// +// swagger:model +type WatchedShowAddRequest struct { + // OPTIONAL – if caller only has the episode-level TMDB id. + TMDBEpisodeID int `json:"tmdbEpisodeId,omitempty"` + // OPTIONAL – show-level TMDB id (preferred). + TMDBID int `json:"tmdbId,omitempty"` + // OPTIONAL – TVDB episode id if webhook only supplies that. + TvdbID int `json:"tvdbId,omitempty"` + + SeasonNumber int `json:"seasonNumber" binding:"required"` + EpisodeNumber int `json:"episodeNumber" binding:"required"` + + // Episode attributes + Status WatchedStatus `json:"status"` + Rating int8 `json:"rating" binding:"max=10"` + + // Show attributes (applied only when show is newly created) + ShowStatus WatchedStatus `json:"showStatus"` + ShowRating float64 `json:"showRating" binding:"max=10"` + + // Optional custom creation date when the show entry is created. + WatchedDate time.Time `json:"watchedDate"` +} + +// addWatchedShowAndEpisode ensures a Watched row exists for the requested TV show and +// then adds / updates the specific episode row. +func addWatchedShowAndEpisode(db *gorm.DB, userId uint, req WatchedShowAddRequest) (Watched, WatchedEpisodeAddResponse, error) { + slog.Debug("addWatchedShowAndEpisode: started", "userId", userId, "req", req) + + // 1. Figure out the show TMDB id. + showTmdbID := req.TMDBID + // First, if we only have a TVDB id, translate it via TMDB find. + if showTmdbID == 0 && req.TMDBEpisodeID == 0 && req.TvdbID != 0 { + epId, shId, err := lookupTMDBIdsFromTVDB(req.TvdbID) + if err != nil { + return Watched{}, WatchedEpisodeAddResponse{}, err + } + // Prefer a direct show match when available – this avoids the extra episode→show + // translation step and prevents 404s when the episode isn't yet indexed by TMDB. + if shId != 0 { + showTmdbID = shId + } else if epId != 0 { + req.TMDBEpisodeID = epId + } + } + + if showTmdbID == 0 { + if req.TMDBEpisodeID == 0 { + slog.Error("addWatchedShowAndEpisode: no usable id supplied") + return Watched{}, WatchedEpisodeAddResponse{}, errors.New("provide tmdbId, tmdbEpisodeId or tvdbId") + } + var err error + showTmdbID, err = lookupShowIdFromEpisode(req.TMDBEpisodeID) + if err != nil { + return Watched{}, WatchedEpisodeAddResponse{}, err + } + } + + // 2. Ensure Content cache exists (will create or update if needed). + if _, err := getOrCacheContent(db, SHOW, showTmdbID); err != nil { + return Watched{}, WatchedEpisodeAddResponse{}, err + } + + // 3. Try obtain existing Watched row. + w, err := getWatchedItemByTmdbId(db, userId, uint(showTmdbID), SHOW) + if err != nil || w.ID == 0 { + // If not found (or any error), create a new Watched row. + wr := WatchedAddRequest{ + Status: defaultShowStatus(req.ShowStatus), + Rating: req.ShowRating, + ContentID: showTmdbID, + ContentType: SHOW, + WatchedDate: req.WatchedDate, + } + w, err = addWatched(db, userId, wr, ADDED_WATCHED) + if err != nil { + return Watched{}, WatchedEpisodeAddResponse{}, err + } + } + + // 4. Always add / update the episode entry. + er := WatchedEpisodeAddRequest{ + WatchedID: w.ID, + SeasonNumber: req.SeasonNumber, + EpisodeNumber: req.EpisodeNumber, + Status: req.Status, + Rating: req.Rating, + } + epResp, err := addWatchedEpisodes(db, userId, er) + if err != nil { + return Watched{}, WatchedEpisodeAddResponse{}, err + } + + slog.Debug("addWatchedShowAndEpisode: finished", "watchedId", w.ID) + return w, epResp, nil +} + +// defaultShowStatus returns WATCHING when blank, otherwise the provided value. +func defaultShowStatus(s WatchedStatus) WatchedStatus { + if s == "" { + return WATCHING + } + return s +} + +// lookupShowIdFromEpisode queries TMDB for the parent show id of the given episode id. +// lookupTMDBIdsFromTVDB translates a TVDB episode id to TMDB ids via the TMDB +// external-id lookup endpoint. It returns (episodeTMDB, showTMDB). +func lookupTMDBIdsFromTVDB(tvdbId int) (int, int, error) { + slog.Debug("lookupTMDBIdsFromTVDB: running", "tvdbId", tvdbId) + type findResp struct { + TvEpisodeResults []struct { + ID int `json:"id"` + ShowID int `json:"show_id"` + } `json:"tv_episode_results"` + TvResults []struct{ ID int `json:"id"` } `json:"tv_results"` + } + var fr findResp + ep := "find/" + strconv.Itoa(tvdbId) + // Strategy: + // 1) Ask TMDB to treat the TVDB id as a *series* id (external_source=tvdb_id). + // If we get a tv_results entry we are done. + // 2) If no series result, ask TMDB to treat the id as an *episode* id + // (external_source=tvdb_episode_id) and capture the TMDB episode id. + // 3) Caller can decide whether the returned episode id needs a further + // /episode/{id} lookup to reach the parent show. + + var epTmdbID, showTmdbID int + + // --- 1. Try treating id as a *series* id first (tvdb_id) ------------------- + if err := tmdbRequest(ep, map[string]string{"external_source": "tvdb_id"}, &fr); err != nil { + return 0, 0, err + } + if len(fr.TvResults) > 0 { + // Best-case: we already have the parent show – nothing else needed. + showTmdbID = fr.TvResults[0].ID + } + // Capture any episode match from this response as well (tvdb_id may still + // populate tv_episode_results). + var firstEpisodeMatch int + var firstEpisodeMatchShowID int + if len(fr.TvEpisodeResults) > 0 { + firstEpisodeMatch = fr.TvEpisodeResults[0].ID + if fr.TvEpisodeResults[0].ShowID != 0 { + firstEpisodeMatchShowID = fr.TvEpisodeResults[0].ShowID + // If we still lacked a show id, use this. + if showTmdbID == 0 { + showTmdbID = firstEpisodeMatchShowID + } + } + } + + // --- 2. If we still don't know the show, try treating id as an episode id -- + if showTmdbID == 0 { + fr = findResp{} + if err := tmdbRequest(ep, map[string]string{"external_source": "tvdb_episode_id"}, &fr); err != nil { + return 0, 0, err + } + if len(fr.TvEpisodeResults) > 0 { + epRes := fr.TvEpisodeResults[0] + epTmdbID = epRes.ID + if epRes.ShowID != 0 { + showTmdbID = epRes.ShowID + } + } else if firstEpisodeMatch != 0 { + epTmdbID = firstEpisodeMatch + if showTmdbID == 0 && firstEpisodeMatchShowID != 0 { + showTmdbID = firstEpisodeMatchShowID + } + } + } + + // Final sanity check + if showTmdbID == 0 && epTmdbID == 0 { + return 0, 0, errors.New("no TMDB match for tvdb id") + } + return epTmdbID, showTmdbID, nil +} + +func lookupShowIdFromEpisode(episodeId int) (int, error) { + slog.Debug("lookupShowIdFromEpisode: running", "episodeId", episodeId) + type tmdbEpisodeDetails struct { + ShowID int `json:"show_id"` + } + var details tmdbEpisodeDetails + ep := "episode/" + strconv.Itoa(episodeId) + if err := tmdbRequest(ep, nil, &details); err != nil { + slog.Error("lookupShowIdFromEpisode: tmdb request failed", "error", err) + return 0, err + } + if details.ShowID == 0 { + return 0, errors.New("failed to resolve parent show id from episode id") + } + slog.Debug("lookupShowIdFromEpisode: success", "showId", details.ShowID) + return details.ShowID, nil +} diff --git a/server/watched_show_route.go b/server/watched_show_route.go new file mode 100644 index 00000000..a5862ba7 --- /dev/null +++ b/server/watched_show_route.go @@ -0,0 +1,84 @@ +package main + +import ( + "context" + "net/http" + "strconv" + "log/slog" + + "github.com/gin-gonic/gin" +) + +// addWatchedShowRoute registers the /watched/emby/show helper that can be called +// either by logged-in users (JWT) or by external services supplying ?api_key=. +func (b *BaseRouter) addWatchedShowRoute() { + b.rg.POST("/watched/emby/show", AuthOrAPIKey(b.db), func(c *gin.Context) { + userId := c.MustGet("userId").(uint) + type embyWebhook struct { + Event string `json:"Event"` + Item struct { + ProviderIds struct { + Tvdb string `json:"Tvdb"` + } `json:"ProviderIds"` + IndexNumber int `json:"IndexNumber"` + ParentIndexNumber int `json:"ParentIndexNumber"` + } `json:"Item"` + PlaybackInfo struct { + PlayedToCompletion bool `json:"PlayedToCompletion"` + } `json:"PlaybackInfo"` + } + var payload embyWebhook + if err := c.ShouldBindJSON(&payload); err != nil { + c.AbortWithStatusJSON(http.StatusBadRequest, ErrorResponse{Error: "invalid request body"}) + return + } + // Accept only completed playback or manual markplayed + if !payload.PlaybackInfo.PlayedToCompletion && payload.Event != "item.markplayed" { + c.JSON(http.StatusBadRequest, gin.H{"error": "not a completed playback or markplayed event"}) + return + } + // Rate-limit TMDB-intensive processing + if err := embyLimiter.Wait(context.Background()); err != nil { + c.AbortWithStatusJSON(http.StatusInternalServerError, ErrorResponse{Error: "rate limiter error"}) + return + } + + slog.Debug("emby webhook parsed", "tvdb", payload.Item.ProviderIds.Tvdb, "season", payload.Item.ParentIndexNumber, "ep", payload.Item.IndexNumber) + tvdbIdStr := payload.Item.ProviderIds.Tvdb + tvdbId, _ := strconv.Atoi(tvdbIdStr) + + // Map TVDB episode id to TMDB S/E numbering via the new 3-call algorithm. + _, seasonNum, episodeNum, err := MapTVDBEpisodeToTMDB(tvdbId) + if err != nil { + slog.Warn("episode mapping failed", "err", err) + // Fallback: keep original season/episode from payload to avoid drop. + seasonNum = payload.Item.ParentIndexNumber + episodeNum = payload.Item.IndexNumber + if episodeNum == 0 { + episodeNum = 1 + } + } + + + + ar := WatchedShowAddRequest{ + TvdbID: tvdbId, + SeasonNumber: seasonNum, + EpisodeNumber: episodeNum, + Status: FINISHED, + Rating: func() int8 { + if Config.EMBY_DEFAULT_RATING != 0 { + return int8(Config.EMBY_DEFAULT_RATING) + } + return 5 + }(), + } + w, epResp, err := addWatchedShowAndEpisode(b.db, userId, ar) + if err != nil { + c.JSON(http.StatusForbidden, ErrorResponse{Error: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"watched": w, "episodeResult": epResp}) + return + }) +} diff --git a/src/lib/settings/APIKeysSetting.svelte b/src/lib/settings/APIKeysSetting.svelte new file mode 100644 index 00000000..eaafbc74 --- /dev/null +++ b/src/lib/settings/APIKeysSetting.svelte @@ -0,0 +1,135 @@ + + +
+ Generate keys for webhooks. Keep them secret! You can only have 5 active at a + time per user. +
+ +{#if loading} +No keys created yet.
+{:else} +| ID | Created | Last Used | |
|---|---|---|---|
| {k.id} | +{dayjs(k.createdAt).format("YYYY-MM-DD")} | +{k.lastUsed && !k.lastUsed.startsWith("0001") + ? dayjs(k.lastUsed).fromNow() + : "Never"} | ++ |
{newKey}
+
+