Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"dependencies": {
"axios": "^1.9.0",
"blurhash": "^2.0.5",
"dayjs": "^1.11.13",
"papaparse": "^5.4.1"
}
}
127 changes: 127 additions & 0 deletions server/api_key.go
Original file line number Diff line number Diff line change
@@ -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()
}
}
50 changes: 50 additions & 0 deletions server/api_key_routes.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
53 changes: 52 additions & 1 deletion server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"path"
"time"
"strconv"

"github.com/sbondCo/Watcharr/game"
"gorm.io/gorm"
Expand Down Expand Up @@ -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"`

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -114,14 +132,20 @@ 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":
return ServerConfigGetByName{Value: c.PLEX_MACHINE_ID}, nil
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")
}
Expand Down Expand Up @@ -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")
Expand All @@ -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")
}
Expand All @@ -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"`
Expand Down
6 changes: 6 additions & 0 deletions server/emby_rate.go
Original file line number Diff line number Diff line change
@@ -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)
52 changes: 52 additions & 0 deletions server/episode_translate.go
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions server/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions server/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
Loading