small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
@@ -0,0 +1,136 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/services/auth"
"github.com/tdvorak/seen/backend/pkg/httpx"
)
type AuthHandler struct {
service *auth.Service
}
func NewAuthHandler(service *auth.Service) *AuthHandler {
return &AuthHandler{service: service}
}
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
func (h *AuthHandler) Me(c *gin.Context) {
accessToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
httpx.JSONError(c, http.StatusUnauthorized, "missing bearer token")
return
}
user, err := h.service.UserFromAccessToken(c.Request.Context(), accessToken)
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidToken):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to resolve user")
}
return
}
httpx.JSON(c, http.StatusOK, user)
}
func (h *AuthHandler) Register(c *gin.Context) {
var request registerRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.service.Register(c.Request.Context(), auth.RegisterInput{
Email: request.Email,
Password: request.Password,
DisplayName: request.DisplayName,
})
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrEmailTaken):
httpx.JSONError(c, http.StatusConflict, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "register failed")
}
return
}
httpx.JSON(c, http.StatusCreated, result)
}
func (h *AuthHandler) Login(c *gin.Context) {
var request loginRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.service.Login(c.Request.Context(), auth.LoginInput{
Email: request.Email,
Password: request.Password,
UserAgent: c.GetHeader("User-Agent"),
IP: c.ClientIP(),
})
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrInvalidCredentials):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "login failed")
}
return
}
httpx.JSON(c, http.StatusOK, result)
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var request refreshRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.service.Refresh(c.Request.Context(), auth.RefreshInput{
RefreshToken: request.RefreshToken,
UserAgent: c.GetHeader("User-Agent"),
IP: c.ClientIP(),
})
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrInvalidSession):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "refresh failed")
}
return
}
httpx.JSON(c, http.StatusOK, result)
}
+22
View File
@@ -0,0 +1,22 @@
package handlers
import "strings"
func bearerToken(header string) (string, bool) {
trimmed := strings.TrimSpace(header)
if trimmed == "" {
return "", false
}
parts := strings.SplitN(trimmed, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
token := strings.TrimSpace(parts[1])
if token == "" {
return "", false
}
return token, true
}
@@ -0,0 +1,227 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/services/auth"
"github.com/tdvorak/seen/backend/internal/services/catalog"
"github.com/tdvorak/seen/backend/pkg/httpx"
)
type CatalogHandler struct {
service *catalog.Service
authService *auth.Service
}
func NewCatalogHandler(service *catalog.Service, authService *auth.Service) *CatalogHandler {
return &CatalogHandler{service: service, authService: authService}
}
type watchLaterAddRequest struct {
MediaID int `json:"mediaId"`
}
type progressUpdateRequest struct {
MediaID int `json:"mediaId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
ProgressPercent int `json:"progressPercent"`
}
func (h *CatalogHandler) Dashboard(c *gin.Context) {
httpx.JSON(c, http.StatusOK, h.service.Dashboard())
}
func (h *CatalogHandler) ContinueWatching(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
items, err := h.service.ContinueWatching(user.ID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to load continue watching")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) Discover(c *gin.Context) {
page := parseInt(c.Query("page"), 1)
pageSize := parseInt(c.Query("pageSize"), 6)
httpx.JSON(c, http.StatusOK, h.service.Discover(catalog.DiscoverParams{
Page: page,
PageSize: pageSize,
Query: c.Query("query"),
Genre: c.Query("genre"),
MediaType: c.Query("mediaType"),
}))
}
func (h *CatalogHandler) Games(c *gin.Context) {
page := parseInt(c.Query("page"), 1)
pageSize := parseInt(c.Query("pageSize"), 6)
httpx.JSON(c, http.StatusOK, h.service.Discover(catalog.DiscoverParams{
Page: page,
PageSize: pageSize,
Query: c.Query("query"),
Genre: c.Query("genre"),
MediaType: string(catalog.MediaTypeGame),
}))
}
func (h *CatalogHandler) Search(c *gin.Context) {
httpx.JSON(c, http.StatusOK, h.service.Search(catalog.SearchParams{
Query: c.Query("query"),
Genre: c.Query("genre"),
MediaType: c.Query("mediaType"),
}))
}
func (h *CatalogHandler) WatchLater(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
items, err := h.service.WatchLater(user.ID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to load watch later")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) AddWatchLater(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
var request watchLaterAddRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
items, err := h.service.AddWatchLater(user.ID, request.MediaID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, catalog.ErrMediaNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to add watch later item")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) RemoveWatchLater(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
mediaID := parseInt(c.Param("mediaId"), 0)
items, err := h.service.RemoveWatchLater(user.ID, mediaID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to remove watch later item")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) UpdateProgress(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
var request progressUpdateRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
items, err := h.service.UpdateProgress(user.ID, catalog.ProgressUpdateInput{
MediaID: request.MediaID,
SeasonNumber: request.SeasonNumber,
EpisodeNumber: request.EpisodeNumber,
ProgressPercent: request.ProgressPercent,
})
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, catalog.ErrMediaNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to update progress")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) resolveUser(c *gin.Context) (*domain.User, bool) {
accessToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
httpx.JSONError(c, http.StatusUnauthorized, "missing bearer token")
return nil, false
}
user, err := h.authService.UserFromAccessToken(c.Request.Context(), accessToken)
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidToken):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to resolve user")
}
return nil, false
}
return user, true
}
func parseInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return parsed
}
@@ -0,0 +1,168 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/services/auth"
"github.com/tdvorak/seen/backend/internal/services/download"
"github.com/tdvorak/seen/backend/pkg/httpx"
)
type DownloadHandler struct {
service *download.Service
authService *auth.Service
}
func NewDownloadHandler(service *download.Service, authService *auth.Service) *DownloadHandler {
return &DownloadHandler{
service: service,
authService: authService,
}
}
type createDownloadRequest struct {
SourceType string `json:"sourceType"`
Source string `json:"source"`
Title string `json:"title"`
}
func (h *DownloadHandler) Create(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
var request createDownloadRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
job, err := h.service.Create(c.Request.Context(), user.ID, download.CreateInput{
SourceType: request.SourceType,
Source: request.Source,
Title: request.Title,
})
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to create download job")
}
return
}
httpx.JSON(c, http.StatusCreated, job)
}
func (h *DownloadHandler) List(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
items, err := h.service.List(c.Request.Context(), user.ID, download.ListParams{
Status: c.Query("status"),
Limit: parseIntSafe(c.Query("limit"), 20),
Offset: parseIntSafe(c.Query("offset"), 0),
})
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to list download jobs")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *DownloadHandler) Cancel(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
jobID := c.Param("id")
job, err := h.service.Cancel(c.Request.Context(), user.ID, jobID)
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, download.ErrNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to cancel download job")
}
return
}
httpx.JSON(c, http.StatusOK, job)
}
func (h *DownloadHandler) Events(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
jobID := c.Param("id")
items, err := h.service.Events(c.Request.Context(), user.ID, jobID, download.EventParams{
After: c.Query("after"),
Limit: parseIntSafe(c.Query("limit"), 100),
})
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, download.ErrNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to list download events")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *DownloadHandler) resolveUser(c *gin.Context) (*domain.User, bool) {
accessToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
httpx.JSONError(c, http.StatusUnauthorized, "missing bearer token")
return nil, false
}
user, err := h.authService.UserFromAccessToken(c.Request.Context(), accessToken)
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidToken):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to resolve user")
}
return nil, false
}
return user, true
}
func parseIntSafe(raw string, fallback int) int {
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return parsed
}
@@ -0,0 +1,49 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
type HealthHandler struct {
db *pgxpool.Pool
cache *redis.Client
}
func NewHealthHandler(db *pgxpool.Pool, cache *redis.Client) *HealthHandler {
return &HealthHandler{db: db, cache: cache}
}
func (h *HealthHandler) Live(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "timestamp": time.Now().UTC()})
}
func (h *HealthHandler) Ready(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
health := gin.H{"status": "ready"}
if err := h.db.Ping(ctx); err != nil {
health["status"] = "degraded"
health["postgres"] = err.Error()
c.JSON(http.StatusServiceUnavailable, health)
return
}
if err := h.cache.Ping(ctx).Err(); err != nil {
health["status"] = "degraded"
health["dragonfly"] = err.Error()
c.JSON(http.StatusServiceUnavailable, health)
return
}
health["postgres"] = "ok"
health["dragonfly"] = "ok"
c.JSON(http.StatusOK, health)
}
@@ -0,0 +1,22 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
type PlaceholderHandler struct{}
func NewPlaceholderHandler() *PlaceholderHandler {
return &PlaceholderHandler{}
}
func (h *PlaceholderHandler) NotImplemented(feature string) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "not implemented",
"feature": feature,
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package v1
import (
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/api/handlers"
)
func Register(
router *gin.RouterGroup,
health *handlers.HealthHandler,
auth *handlers.AuthHandler,
catalog *handlers.CatalogHandler,
download *handlers.DownloadHandler,
placeholder *handlers.PlaceholderHandler,
) {
router.GET("/health/live", health.Live)
router.GET("/health/ready", health.Ready)
authGroup := router.Group("/auth")
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.GET("/me", auth.Me)
router.GET("/dashboard", catalog.Dashboard)
router.GET("/progress/continue-watching", catalog.ContinueWatching)
router.GET("/discover", catalog.Discover)
router.GET("/games", catalog.Games)
router.GET("/search", catalog.Search)
router.GET("/watch-later", catalog.WatchLater)
router.POST("/watch-later", catalog.AddWatchLater)
router.DELETE("/watch-later/:mediaId", catalog.RemoveWatchLater)
router.POST("/progress", catalog.UpdateProgress)
router.GET("/movies", placeholder.NotImplemented("movies"))
router.GET("/shows", placeholder.NotImplemented("shows"))
router.GET("/watched", placeholder.NotImplemented("watched"))
router.GET("/watchlist", placeholder.NotImplemented("watchlist"))
router.GET("/progress", placeholder.NotImplemented("progress"))
router.POST("/downloads", download.Create)
router.GET("/downloads", download.List)
router.DELETE("/downloads/:id", download.Cancel)
router.GET("/downloads/:id/events", download.Events)
router.GET("/calendar", placeholder.NotImplemented("calendar"))
router.GET("/library", placeholder.NotImplemented("library"))
router.GET("/collections", placeholder.NotImplemented("collections"))
router.GET("/settings", placeholder.NotImplemented("settings"))
router.GET("/admin", placeholder.NotImplemented("admin"))
router.GET("/recommendations", placeholder.NotImplemented("recommendations"))
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/api/handlers"
v1 "github.com/tdvorak/seen/backend/internal/api/routes/v1"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/middleware"
"go.uber.org/zap"
)
type Handlers struct {
Health *handlers.HealthHandler
Auth *handlers.AuthHandler
Catalog *handlers.CatalogHandler
Download *handlers.DownloadHandler
Placeholder *handlers.PlaceholderHandler
}
func NewRouter(cfg config.Config, log *zap.Logger, handlers Handlers) *gin.Engine {
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
engine.Use(middleware.RequestID())
engine.Use(middleware.CORS(cfg.CORS))
engine.Use(middleware.AccessLog(log))
engine.Use(middleware.Recovery(log))
engine.GET("/healthz", handlers.Health.Live)
apiV1 := engine.Group("/api/v1")
v1.Register(apiV1, handlers.Health, handlers.Auth, handlers.Catalog, handlers.Download, handlers.Placeholder)
return engine
}
+194
View File
@@ -0,0 +1,194 @@
package config
import (
"fmt"
"os"
"slices"
"strconv"
"strings"
"time"
"github.com/joho/godotenv"
)
type Config struct {
Env string
AppName string
HTTP HTTPConfig
CORS CORSConfig
Postgres PostgresConfig
Cache CacheConfig
Auth AuthConfig
TMDB TMDBConfig
IGDB IGDBConfig
}
type HTTPConfig struct {
Host string
Port int
ReadTimeout time.Duration
WriteTimeout time.Duration
}
type CORSConfig struct {
AllowedOrigins []string
AllowedMethods []string
AllowedHeaders []string
ExposedHeaders []string
AllowCredentials bool
MaxAge time.Duration
}
type PostgresConfig struct {
URL string
MaxConns int32
MinConns int32
}
type CacheConfig struct {
Addr string
Password string
DB int
}
type AuthConfig struct {
AccessTokenTTLMinutes int
RefreshTokenTTLHours int
JWTSecret string
}
type TMDBConfig struct {
APIKey string
BaseURL string
}
type IGDBConfig struct {
ClientID string
ClientSecret string
BaseURL string
TokenURL string
}
func Load() Config {
_ = godotenv.Load()
return Config{
Env: getString("SEEN_ENV", "development"),
AppName: getString("SEEN_APP_NAME", "seen"),
HTTP: HTTPConfig{
Host: getString("SEEN_HTTP_HOST", "0.0.0.0"),
Port: getInt("SEEN_HTTP_PORT", 8081),
ReadTimeout: getDuration("SEEN_HTTP_READ_TIMEOUT", 15*time.Second),
WriteTimeout: getDuration("SEEN_HTTP_WRITE_TIMEOUT", 15*time.Second),
},
CORS: CORSConfig{
AllowedOrigins: getCSV("SEEN_CORS_ALLOWED_ORIGINS", []string{}),
AllowedMethods: getCSV("SEEN_CORS_ALLOWED_METHODS", []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}),
AllowedHeaders: getCSV("SEEN_CORS_ALLOWED_HEADERS", []string{"Accept", "Authorization", "Content-Type", "X-Request-ID"}),
ExposedHeaders: getCSV("SEEN_CORS_EXPOSED_HEADERS", []string{"X-Request-ID"}),
AllowCredentials: getBool("SEEN_CORS_ALLOW_CREDENTIALS", false),
MaxAge: getDuration("SEEN_CORS_MAX_AGE", 24*time.Hour),
},
Postgres: PostgresConfig{
URL: getString("SEEN_POSTGRES_URL", "postgres://seen:seen@localhost:5432/seen?sslmode=disable"),
MaxConns: int32(getInt("SEEN_POSTGRES_MAX_CONNS", 10)),
MinConns: int32(getInt("SEEN_POSTGRES_MIN_CONNS", 2)),
},
Cache: CacheConfig{
Addr: getString("SEEN_CACHE_ADDR", "localhost:6379"),
Password: getString("SEEN_CACHE_PASSWORD", ""),
DB: getInt("SEEN_CACHE_DB", 0),
},
Auth: AuthConfig{
AccessTokenTTLMinutes: getInt("SEEN_AUTH_ACCESS_TOKEN_TTL_MINUTES", 30),
RefreshTokenTTLHours: getInt("SEEN_AUTH_REFRESH_TOKEN_TTL_HOURS", 24*30),
JWTSecret: getString("SEEN_AUTH_JWT_SECRET", "replace-in-production"),
},
TMDB: TMDBConfig{
APIKey: getString("SEEN_TMDB_API_KEY", ""),
BaseURL: getString("SEEN_TMDB_BASE_URL", "https://api.themoviedb.org/3"),
},
IGDB: IGDBConfig{
ClientID: getString("SEEN_IGDB_CLIENT_ID", ""),
ClientSecret: getString("SEEN_IGDB_CLIENT_SECRET", ""),
BaseURL: getString("SEEN_IGDB_BASE_URL", "https://api.igdb.com/v4"),
TokenURL: getString("SEEN_IGDB_TOKEN_URL", "https://id.twitch.tv/oauth2/token"),
},
}
}
func (c HTTPConfig) Addr() string {
return fmt.Sprintf("%s:%d", c.Host, c.Port)
}
func getString(key string, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}
func getInt(key string, fallback int) int {
value, ok := os.LookupEnv(key)
if !ok {
return fallback
}
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}
func getDuration(key string, fallback time.Duration) time.Duration {
value, ok := os.LookupEnv(key)
if !ok {
return fallback
}
parsed, err := time.ParseDuration(value)
if err != nil {
return fallback
}
return parsed
}
func getBool(key string, fallback bool) bool {
value, ok := os.LookupEnv(key)
if !ok {
return fallback
}
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return fallback
}
}
func getCSV(key string, fallback []string) []string {
value, ok := os.LookupEnv(key)
if !ok {
return slices.Clone(fallback)
}
parts := strings.Split(value, ",")
items := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
items = append(items, trimmed)
}
if len(items) == 0 {
return slices.Clone(fallback)
}
return items
}
+71
View File
@@ -0,0 +1,71 @@
package config
import (
"testing"
"time"
)
func TestLoadDefaults(t *testing.T) {
t.Setenv("SEEN_HTTP_PORT", "invalid")
t.Setenv("SEEN_HTTP_READ_TIMEOUT", "invalid")
cfg := Load()
if cfg.HTTP.Port != 8081 {
t.Fatalf("expected default port 8081, got %d", cfg.HTTP.Port)
}
if cfg.HTTP.ReadTimeout != 15*time.Second {
t.Fatalf("expected default read timeout, got %s", cfg.HTTP.ReadTimeout)
}
}
func TestLoadCustomValues(t *testing.T) {
t.Setenv("SEEN_ENV", "test")
t.Setenv("SEEN_HTTP_PORT", "9090")
t.Setenv("SEEN_HTTP_READ_TIMEOUT", "5s")
t.Setenv("SEEN_POSTGRES_MAX_CONNS", "22")
t.Setenv("SEEN_CACHE_DB", "3")
t.Setenv("SEEN_IGDB_CLIENT_ID", "client-id")
t.Setenv("SEEN_IGDB_TOKEN_URL", "https://id.twitch.tv/oauth2/token")
t.Setenv("SEEN_CORS_ALLOWED_ORIGINS", "https://seen.example.com, https://www.seen.example.com")
t.Setenv("SEEN_CORS_ALLOW_CREDENTIALS", "true")
cfg := Load()
if cfg.Env != "test" {
t.Fatalf("expected env test, got %q", cfg.Env)
}
if cfg.HTTP.Port != 9090 {
t.Fatalf("expected http port 9090, got %d", cfg.HTTP.Port)
}
if cfg.HTTP.ReadTimeout != 5*time.Second {
t.Fatalf("expected read timeout 5s, got %s", cfg.HTTP.ReadTimeout)
}
if cfg.Postgres.MaxConns != 22 {
t.Fatalf("expected max conns 22, got %d", cfg.Postgres.MaxConns)
}
if cfg.Cache.DB != 3 {
t.Fatalf("expected cache db 3, got %d", cfg.Cache.DB)
}
if cfg.IGDB.ClientID != "client-id" {
t.Fatalf("expected igdb client id to load, got %q", cfg.IGDB.ClientID)
}
if cfg.IGDB.TokenURL != "https://id.twitch.tv/oauth2/token" {
t.Fatalf("expected igdb token url to load, got %q", cfg.IGDB.TokenURL)
}
if len(cfg.CORS.AllowedOrigins) != 2 {
t.Fatalf("expected 2 cors origins, got %d", len(cfg.CORS.AllowedOrigins))
}
if !cfg.CORS.AllowCredentials {
t.Fatal("expected allow credentials to be true")
}
}
+18
View File
@@ -0,0 +1,18 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type Session struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"userId"`
RefreshToken string `json:"refreshToken"`
UserAgent string `json:"userAgent"`
IP string `json:"ip"`
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"revokedAt,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
+24
View File
@@ -0,0 +1,24 @@
package domain
import (
"time"
"github.com/google/uuid"
)
type Role string
const (
RoleUser Role = "user"
RoleAdmin Role = "admin"
)
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role Role `json:"role"`
PasswordHash string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
+8
View File
@@ -0,0 +1,8 @@
package downloader
import "context"
type Engine interface {
Add(ctx context.Context, source string) (string, error)
Status(ctx context.Context, id string) (string, error)
}
+34
View File
@@ -0,0 +1,34 @@
package downloader
import (
"context"
"time"
"go.uber.org/zap"
)
type Worker struct {
log *zap.Logger
}
func NewWorker(log *zap.Logger) *Worker {
return &Worker{log: log}
}
func (w *Worker) Name() string {
return "downloader-monitor"
}
func (w *Worker) Start(ctx context.Context) error {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
w.log.Debug("downloader heartbeat")
}
}
}
+136
View File
@@ -0,0 +1,136 @@
package cache
import (
"context"
"fmt"
)
// CatalogCache provides caching for catalog operations
type CatalogCache struct {
service *Service
keys *KeyBuilder
}
// NewCatalogCache creates a new catalog cache
func NewCatalogCache(service *Service, namespace string) *CatalogCache {
return &CatalogCache{
service: service,
keys: NewKeyBuilder(namespace),
}
}
// GetDashboard retrieves cached dashboard data
func (cc *CatalogCache) GetDashboard(ctx context.Context, userID string, target interface{}) error {
key := cc.keys.CatalogDashboardKey(userID)
return cc.service.Get(ctx, key, target)
}
// SetDashboard stores dashboard data in cache
func (cc *CatalogCache) SetDashboard(ctx context.Context, userID string, data interface{}) error {
key := cc.keys.CatalogDashboardKey(userID)
return cc.service.Set(ctx, key, data, TTLCatalog)
}
// InvalidateDashboard removes cached dashboard data
func (cc *CatalogCache) InvalidateDashboard(ctx context.Context, userID string) error {
key := cc.keys.CatalogDashboardKey(userID)
return cc.service.Delete(ctx, key)
}
// GetDiscover retrieves cached discover sections
func (cc *CatalogCache) GetDiscover(ctx context.Context, genre, mediaType string, page int, target interface{}) error {
key := cc.keys.CatalogDiscoverKey(genre, mediaType, page)
return cc.service.Get(ctx, key, target)
}
// SetDiscover stores discover sections in cache
func (cc *CatalogCache) SetDiscover(ctx context.Context, genre, mediaType string, page int, data interface{}) error {
key := cc.keys.CatalogDiscoverKey(genre, mediaType, page)
return cc.service.Set(ctx, key, data, TTLCatalog)
}
// GetSearch retrieves cached search results
func (cc *CatalogCache) GetSearch(ctx context.Context, query, genre, mediaType string, target interface{}) error {
key := cc.keys.CatalogSearchKey(query, genre, mediaType)
return cc.service.Get(ctx, key, target)
}
// SetSearch stores search results in cache
func (cc *CatalogCache) SetSearch(ctx context.Context, query, genre, mediaType string, data interface{}) error {
key := cc.keys.CatalogSearchKey(query, genre, mediaType)
return cc.service.Set(ctx, key, data, TTLSearch)
}
// GetWatchLater retrieves cached watch later list
func (cc *CatalogCache) GetWatchLater(ctx context.Context, userID string, target interface{}) error {
key := cc.keys.WatchLaterKey(userID)
return cc.service.Get(ctx, key, target)
}
// SetWatchLater stores watch later list in cache
func (cc *CatalogCache) SetWatchLater(ctx context.Context, userID string, data interface{}) error {
key := cc.keys.WatchLaterKey(userID)
return cc.service.Set(ctx, key, data, TTLCatalog)
}
// InvalidateWatchLater removes cached watch later list
func (cc *CatalogCache) InvalidateWatchLater(ctx context.Context, userID string) error {
key := cc.keys.WatchLaterKey(userID)
return cc.service.Delete(ctx, key)
}
// GetContinueWatching retrieves cached continue watching list
func (cc *CatalogCache) GetContinueWatching(ctx context.Context, userID string, target interface{}) error {
key := cc.keys.ContinueWatchingKey(userID)
return cc.service.Get(ctx, key, target)
}
// SetContinueWatching stores continue watching list in cache
func (cc *CatalogCache) SetContinueWatching(ctx context.Context, userID string, data interface{}) error {
key := cc.keys.ContinueWatchingKey(userID)
return cc.service.Set(ctx, key, data, TTLCatalog)
}
// InvalidateContinueWatching removes cached continue watching list
func (cc *CatalogCache) InvalidateContinueWatching(ctx context.Context, userID string) error {
key := cc.keys.ContinueWatchingKey(userID)
return cc.service.Delete(ctx, key)
}
// InvalidateUserCatalog removes all cached catalog data for a user
func (cc *CatalogCache) InvalidateUserCatalog(ctx context.Context, userID string) error {
keys := []string{
cc.keys.CatalogDashboardKey(userID),
cc.keys.WatchLaterKey(userID),
cc.keys.ContinueWatchingKey(userID),
}
return cc.service.Delete(ctx, keys...)
}
// GetRecommendations retrieves cached recommendations
func (cc *CatalogCache) GetRecommendations(ctx context.Context, userID string, target interface{}) error {
key := cc.keys.RecommendationKey(userID)
return cc.service.Get(ctx, key, target)
}
// SetRecommendations stores recommendations in cache
func (cc *CatalogCache) SetRecommendations(ctx context.Context, userID string, data interface{}) error {
key := cc.keys.RecommendationKey(userID)
return cc.service.Set(ctx, key, data, TTLRecommendation)
}
// InvalidateRecommendations removes cached recommendations
func (cc *CatalogCache) InvalidateRecommendations(ctx context.Context, userID string) error {
key := cc.keys.RecommendationKey(userID)
return cc.service.Delete(ctx, key)
}
// WarmupCache pre-populates cache with common queries
func (cc *CatalogCache) WarmupCache(ctx context.Context, warmupFunc func(ctx context.Context) error) error {
if warmupFunc == nil {
return fmt.Errorf("warmup function is required")
}
return warmupFunc(ctx)
}
+209
View File
@@ -0,0 +1,209 @@
package cache
import (
"context"
"fmt"
"time"
)
// DownloadProgress represents real-time download progress
type DownloadProgress struct {
JobID string `json:"jobId"`
Status string `json:"status"`
ProgressPercent int `json:"progressPercent"`
BytesTotal int64 `json:"bytesTotal"`
BytesDownloaded int64 `json:"bytesDownloaded"`
DownloadSpeedMbps float64 `json:"downloadSpeedMbps"`
EtaSeconds int `json:"etaSeconds"`
UpdatedAt time.Time `json:"updatedAt"`
}
// DownloadCache provides caching for download operations
type DownloadCache struct {
service *Service
keys *KeyBuilder
}
// NewDownloadCache creates a new download cache
func NewDownloadCache(service *Service, namespace string) *DownloadCache {
return &DownloadCache{
service: service,
keys: NewKeyBuilder(namespace),
}
}
// SetProgress stores download progress in cache
func (dc *DownloadCache) SetProgress(ctx context.Context, progress DownloadProgress) error {
key := dc.keys.DownloadJobKey(progress.JobID)
progress.UpdatedAt = time.Now().UTC()
return dc.service.Set(ctx, key, progress, TTLDownload)
}
// GetProgress retrieves download progress from cache
func (dc *DownloadCache) GetProgress(ctx context.Context, jobID string) (*DownloadProgress, error) {
key := dc.keys.DownloadJobKey(jobID)
var progress DownloadProgress
if err := dc.service.Get(ctx, key, &progress); err != nil {
return nil, err
}
return &progress, nil
}
// DeleteProgress removes download progress from cache
func (dc *DownloadCache) DeleteProgress(ctx context.Context, jobID string) error {
key := dc.keys.DownloadJobKey(jobID)
return dc.service.Delete(ctx, key)
}
// GetUserDownloads retrieves cached download list for a user
func (dc *DownloadCache) GetUserDownloads(ctx context.Context, userID, status string, target interface{}) error {
key := dc.keys.DownloadListKey(userID, status)
return dc.service.Get(ctx, key, target)
}
// SetUserDownloads stores download list in cache
func (dc *DownloadCache) SetUserDownloads(ctx context.Context, userID, status string, data interface{}) error {
key := dc.keys.DownloadListKey(userID, status)
return dc.service.Set(ctx, key, data, TTLDownload)
}
// InvalidateUserDownloads removes cached download list
func (dc *DownloadCache) InvalidateUserDownloads(ctx context.Context, userID string) error {
// Invalidate all status variations
statuses := []string{"", "queued", "preparing", "downloading", "completed", "failed", "cancelled"}
keys := make([]string, 0, len(statuses))
for _, status := range statuses {
keys = append(keys, dc.keys.DownloadListKey(userID, status))
}
return dc.service.Delete(ctx, keys...)
}
// UpdateProgressField updates a specific field of download progress
func (dc *DownloadCache) UpdateProgressField(ctx context.Context, jobID string, updateFunc func(*DownloadProgress)) error {
progress, err := dc.GetProgress(ctx, jobID)
if err != nil {
if err == ErrCacheMiss {
// Create new progress entry
progress = &DownloadProgress{
JobID: jobID,
UpdatedAt: time.Now().UTC(),
}
} else {
return err
}
}
updateFunc(progress)
return dc.SetProgress(ctx, *progress)
}
// IncrementDownloadedBytes atomically increments downloaded bytes
func (dc *DownloadCache) IncrementDownloadedBytes(ctx context.Context, jobID string, bytes int64) error {
return dc.UpdateProgressField(ctx, jobID, func(p *DownloadProgress) {
p.BytesDownloaded += bytes
if p.BytesTotal > 0 {
p.ProgressPercent = int((p.BytesDownloaded * 100) / p.BytesTotal)
}
})
}
// SetDownloadSpeed updates the download speed
func (dc *DownloadCache) SetDownloadSpeed(ctx context.Context, jobID string, speedMbps float64) error {
return dc.UpdateProgressField(ctx, jobID, func(p *DownloadProgress) {
p.DownloadSpeedMbps = speedMbps
// Calculate ETA if we have speed and remaining bytes
if speedMbps > 0 && p.BytesTotal > 0 {
remainingBytes := p.BytesTotal - p.BytesDownloaded
if remainingBytes > 0 {
// Convert Mbps to bytes per second
bytesPerSecond := (speedMbps * 1024 * 1024) / 8
p.EtaSeconds = int(float64(remainingBytes) / bytesPerSecond)
}
}
})
}
// GetActiveDownloads retrieves all active download jobs
func (dc *DownloadCache) GetActiveDownloads(ctx context.Context) ([]DownloadProgress, error) {
pattern := dc.keys.Build(PrefixDownload, "job", "*")
keys, err := dc.service.Keys(ctx, pattern)
if err != nil {
return nil, err
}
downloads := make([]DownloadProgress, 0, len(keys))
for _, key := range keys {
var progress DownloadProgress
if err := dc.service.Get(ctx, key, &progress); err != nil {
continue
}
// Only include active downloads
if progress.Status == "downloading" || progress.Status == "preparing" {
downloads = append(downloads, progress)
}
}
return downloads, nil
}
// CleanupStaleProgress removes progress entries that haven't been updated recently
func (dc *DownloadCache) CleanupStaleProgress(ctx context.Context, maxAge time.Duration) error {
pattern := dc.keys.Build(PrefixDownload, "job", "*")
keys, err := dc.service.Keys(ctx, pattern)
if err != nil {
return err
}
now := time.Now().UTC()
toDelete := make([]string, 0)
for _, key := range keys {
var progress DownloadProgress
if err := dc.service.Get(ctx, key, &progress); err != nil {
continue
}
if now.Sub(progress.UpdatedAt) > maxAge {
toDelete = append(toDelete, key)
}
}
if len(toDelete) > 0 {
return dc.service.Delete(ctx, toDelete...)
}
return nil
}
// BulkSetProgress stores multiple download progress entries at once
func (dc *DownloadCache) BulkSetProgress(ctx context.Context, progressList []DownloadProgress) error {
if len(progressList) == 0 {
return nil
}
pairs := make(map[string]interface{}, len(progressList))
for _, progress := range progressList {
key := dc.keys.DownloadJobKey(progress.JobID)
progress.UpdatedAt = time.Now().UTC()
pairs[key] = progress
}
if err := dc.service.MSet(ctx, pairs); err != nil {
return err
}
// Set TTL for each key
for key := range pairs {
if err := dc.service.Expire(ctx, key, TTLDownload); err != nil {
return fmt.Errorf("failed to set TTL for %s: %w", key, err)
}
}
return nil
}
+29
View File
@@ -0,0 +1,29 @@
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/tdvorak/seen/backend/internal/config"
"go.uber.org/zap"
)
func NewClient(ctx context.Context, cfg config.CacheConfig, log *zap.Logger) (*redis.Client, error) {
client := redis.NewClient(&redis.Options{
Addr: cfg.Addr,
Password: cfg.Password,
DB: cfg.DB,
DialTimeout: 5 * time.Second,
ReadTimeout: 3 * time.Second,
WriteTimeout: 3 * time.Second,
})
if err := client.Ping(ctx).Err(); err != nil {
return nil, fmt.Errorf("ping dragonfly: %w", err)
}
log.Info("dragonfly connected", zap.String("addr", cfg.Addr), zap.Int("db", cfg.DB))
return client, nil
}
+129
View File
@@ -0,0 +1,129 @@
package cache
import (
"fmt"
"time"
)
// Key prefixes for different data types
const (
PrefixSession = "session"
PrefixUser = "user"
PrefixCatalog = "catalog"
PrefixDownload = "download"
PrefixRateLimit = "ratelimit"
PrefixLock = "lock"
PrefixSearch = "search"
PrefixRecommendation = "recommendation"
)
// TTL constants
const (
TTLSession = 24 * time.Hour
TTLUser = 15 * time.Minute
TTLCatalog = 5 * time.Minute
TTLDownload = 30 * time.Second
TTLRateLimit = 1 * time.Minute
TTLLock = 30 * time.Second
TTLSearch = 10 * time.Minute
TTLRecommendation = 1 * time.Hour
)
// KeyBuilder provides methods to build cache keys
type KeyBuilder struct {
namespace string
}
// NewKeyBuilder creates a new key builder with a namespace
func NewKeyBuilder(namespace string) *KeyBuilder {
return &KeyBuilder{namespace: namespace}
}
// Build creates a cache key from parts
func (kb *KeyBuilder) Build(parts ...string) string {
if kb.namespace == "" {
return join(parts...)
}
return join(append([]string{kb.namespace}, parts...)...)
}
// SessionKey builds a key for session data
func (kb *KeyBuilder) SessionKey(sessionID string) string {
return kb.Build(PrefixSession, sessionID)
}
// UserKey builds a key for user data
func (kb *KeyBuilder) UserKey(userID string) string {
return kb.Build(PrefixUser, userID)
}
// UserProfileKey builds a key for user profile data
func (kb *KeyBuilder) UserProfileKey(userID string) string {
return kb.Build(PrefixUser, userID, "profile")
}
// CatalogDashboardKey builds a key for dashboard data
func (kb *KeyBuilder) CatalogDashboardKey(userID string) string {
return kb.Build(PrefixCatalog, "dashboard", userID)
}
// CatalogDiscoverKey builds a key for discover sections
func (kb *KeyBuilder) CatalogDiscoverKey(genre, mediaType string, page int) string {
return kb.Build(PrefixCatalog, "discover", genre, mediaType, fmt.Sprintf("page:%d", page))
}
// CatalogSearchKey builds a key for search results
func (kb *KeyBuilder) CatalogSearchKey(query, genre, mediaType string) string {
return kb.Build(PrefixSearch, query, genre, mediaType)
}
// DownloadJobKey builds a key for download job data
func (kb *KeyBuilder) DownloadJobKey(jobID string) string {
return kb.Build(PrefixDownload, "job", jobID)
}
// DownloadListKey builds a key for user's download list
func (kb *KeyBuilder) DownloadListKey(userID string, status string) string {
return kb.Build(PrefixDownload, "list", userID, status)
}
// RateLimitKey builds a key for rate limiting
func (kb *KeyBuilder) RateLimitKey(identifier, action string) string {
return kb.Build(PrefixRateLimit, identifier, action)
}
// LockKey builds a key for distributed locks
func (kb *KeyBuilder) LockKey(resource string) string {
return kb.Build(PrefixLock, resource)
}
// RecommendationKey builds a key for user recommendations
func (kb *KeyBuilder) RecommendationKey(userID string) string {
return kb.Build(PrefixRecommendation, userID)
}
// WatchLaterKey builds a key for watch later list
func (kb *KeyBuilder) WatchLaterKey(userID string) string {
return kb.Build(PrefixCatalog, "watchlater", userID)
}
// ContinueWatchingKey builds a key for continue watching list
func (kb *KeyBuilder) ContinueWatchingKey(userID string) string {
return kb.Build(PrefixCatalog, "continue", userID)
}
// join concatenates strings with a colon separator
func join(parts ...string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for i := 1; i < len(parts); i++ {
if parts[i] != "" {
result += ":" + parts[i]
}
}
return result
}
+235
View File
@@ -0,0 +1,235 @@
package cache
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"github.com/tdvorak/seen/backend/internal/config"
"go.uber.org/zap"
)
// Manager provides centralized cache management
type Manager struct {
client *redis.Client
service *Service
session *SessionCache
catalog *CatalogCache
download *DownloadCache
log *zap.Logger
}
// NewManager creates a new cache manager with all cache services
func NewManager(ctx context.Context, cfg config.CacheConfig, log *zap.Logger) (*Manager, error) {
client, err := NewClient(ctx, cfg, log)
if err != nil {
return nil, fmt.Errorf("failed to create cache client: %w", err)
}
service := NewService(client)
namespace := "seen"
manager := &Manager{
client: client,
service: service,
session: NewSessionCache(service, namespace),
catalog: NewCatalogCache(service, namespace),
download: NewDownloadCache(service, namespace),
log: log,
}
// Start background cleanup tasks
go manager.startCleanupTasks(ctx)
return manager, nil
}
// Client returns the underlying Redis client
func (m *Manager) Client() *redis.Client {
return m.client
}
// Service returns the cache service
func (m *Manager) Service() *Service {
return m.service
}
// Session returns the session cache
func (m *Manager) Session() *SessionCache {
return m.session
}
// Catalog returns the catalog cache
func (m *Manager) Catalog() *CatalogCache {
return m.catalog
}
// Download returns the download cache
func (m *Manager) Download() *DownloadCache {
return m.download
}
// Ping checks if the cache is responsive
func (m *Manager) Ping(ctx context.Context) error {
return m.service.Ping(ctx)
}
// Close closes all cache connections
func (m *Manager) Close() error {
m.log.Info("closing cache connections")
return m.service.Close()
}
// Stats returns cache statistics
func (m *Manager) Stats(ctx context.Context) (map[string]interface{}, error) {
info, err := m.client.Info(ctx, "stats", "memory", "keyspace").Result()
if err != nil {
return nil, fmt.Errorf("failed to get cache stats: %w", err)
}
dbSize, err := m.client.DBSize(ctx).Result()
if err != nil {
return nil, fmt.Errorf("failed to get db size: %w", err)
}
stats := map[string]interface{}{
"dbSize": dbSize,
"info": info,
}
return stats, nil
}
// FlushAll clears all cache data (use with extreme caution!)
func (m *Manager) FlushAll(ctx context.Context) error {
m.log.Warn("flushing all cache data")
return m.service.FlushDB(ctx)
}
// startCleanupTasks starts background tasks for cache maintenance
func (m *Manager) startCleanupTasks(ctx context.Context) {
ticker := time.NewTicker(5 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
m.log.Info("stopping cache cleanup tasks")
return
case <-ticker.C:
m.runCleanupTasks(ctx)
}
}
}
// runCleanupTasks performs periodic cache maintenance
func (m *Manager) runCleanupTasks(ctx context.Context) {
// Cleanup stale download progress (older than 1 hour)
if err := m.download.CleanupStaleProgress(ctx, 1*time.Hour); err != nil {
m.log.Error("failed to cleanup stale download progress", zap.Error(err))
}
// Log cache stats
stats, err := m.Stats(ctx)
if err != nil {
m.log.Error("failed to get cache stats", zap.Error(err))
return
}
if dbSize, ok := stats["dbSize"].(int64); ok {
m.log.Debug("cache stats", zap.Int64("keys", dbSize))
}
}
// InvalidateUser removes all cached data for a user
func (m *Manager) InvalidateUser(ctx context.Context, userID string) error {
m.log.Info("invalidating user cache", zap.String("userId", userID))
// Invalidate user data
if err := m.session.DeleteUser(ctx, userID); err != nil {
m.log.Error("failed to delete user cache", zap.Error(err))
}
// Invalidate user sessions
if err := m.session.InvalidateUserSessions(ctx, userID); err != nil {
m.log.Error("failed to invalidate user sessions", zap.Error(err))
}
// Invalidate catalog data
if err := m.catalog.InvalidateUserCatalog(ctx, userID); err != nil {
m.log.Error("failed to invalidate user catalog", zap.Error(err))
}
// Invalidate download data
if err := m.download.InvalidateUserDownloads(ctx, userID); err != nil {
m.log.Error("failed to invalidate user downloads", zap.Error(err))
}
return nil
}
// WarmupCache pre-populates cache with common data
func (m *Manager) WarmupCache(ctx context.Context) error {
m.log.Info("warming up cache")
// Add warmup logic here as needed
// For example, pre-cache popular catalog sections
return nil
}
// HealthCheck performs a comprehensive health check
func (m *Manager) HealthCheck(ctx context.Context) error {
// Check basic connectivity
if err := m.Ping(ctx); err != nil {
return fmt.Errorf("ping failed: %w", err)
}
// Test write operation
testKey := "health:check"
testValue := map[string]interface{}{
"timestamp": time.Now().UTC(),
"test": true,
}
if err := m.service.Set(ctx, testKey, testValue, 10*time.Second); err != nil {
return fmt.Errorf("write test failed: %w", err)
}
// Test read operation
var readValue map[string]interface{}
if err := m.service.Get(ctx, testKey, &readValue); err != nil {
return fmt.Errorf("read test failed: %w", err)
}
// Cleanup test key
if err := m.service.Delete(ctx, testKey); err != nil {
m.log.Warn("failed to cleanup health check key", zap.Error(err))
}
return nil
}
// GetKeysByPattern retrieves all keys matching a pattern
func (m *Manager) GetKeysByPattern(ctx context.Context, pattern string) ([]string, error) {
return m.service.Keys(ctx, pattern)
}
// DeleteKeysByPattern deletes all keys matching a pattern
func (m *Manager) DeleteKeysByPattern(ctx context.Context, pattern string) error {
keys, err := m.service.Keys(ctx, pattern)
if err != nil {
return err
}
if len(keys) == 0 {
return nil
}
m.log.Info("deleting keys by pattern",
zap.String("pattern", pattern),
zap.Int("count", len(keys)))
return m.service.Delete(ctx, keys...)
}
+251
View File
@@ -0,0 +1,251 @@
package cache
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
var (
ErrCacheMiss = errors.New("cache miss")
ErrCacheSet = errors.New("cache set failed")
)
// Service provides high-level caching operations
type Service struct {
client *redis.Client
}
// NewService creates a new cache service
func NewService(client *redis.Client) *Service {
return &Service{client: client}
}
// Get retrieves a value from cache and unmarshals it into the target
func (s *Service) Get(ctx context.Context, key string, target interface{}) error {
data, err := s.client.Get(ctx, key).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return ErrCacheMiss
}
return fmt.Errorf("cache get: %w", err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("cache unmarshal: %w", err)
}
return nil
}
// Set stores a value in cache with the given TTL
func (s *Service) Set(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("cache marshal: %w", err)
}
if err := s.client.Set(ctx, key, data, ttl).Err(); err != nil {
return fmt.Errorf("%w: %v", ErrCacheSet, err)
}
return nil
}
// Delete removes a key from cache
func (s *Service) Delete(ctx context.Context, keys ...string) error {
if len(keys) == 0 {
return nil
}
if err := s.client.Del(ctx, keys...).Err(); err != nil {
return fmt.Errorf("cache delete: %w", err)
}
return nil
}
// Exists checks if a key exists in cache
func (s *Service) Exists(ctx context.Context, key string) (bool, error) {
count, err := s.client.Exists(ctx, key).Result()
if err != nil {
return false, fmt.Errorf("cache exists: %w", err)
}
return count > 0, nil
}
// Expire sets a TTL on an existing key
func (s *Service) Expire(ctx context.Context, key string, ttl time.Duration) error {
if err := s.client.Expire(ctx, key, ttl).Err(); err != nil {
return fmt.Errorf("cache expire: %w", err)
}
return nil
}
// TTL returns the remaining time to live for a key
func (s *Service) TTL(ctx context.Context, key string) (time.Duration, error) {
ttl, err := s.client.TTL(ctx, key).Result()
if err != nil {
return 0, fmt.Errorf("cache ttl: %w", err)
}
return ttl, nil
}
// Increment atomically increments a counter
func (s *Service) Increment(ctx context.Context, key string) (int64, error) {
val, err := s.client.Incr(ctx, key).Result()
if err != nil {
return 0, fmt.Errorf("cache increment: %w", err)
}
return val, nil
}
// IncrementBy atomically increments a counter by a specific amount
func (s *Service) IncrementBy(ctx context.Context, key string, amount int64) (int64, error) {
val, err := s.client.IncrBy(ctx, key, amount).Result()
if err != nil {
return 0, fmt.Errorf("cache increment by: %w", err)
}
return val, nil
}
// SetNX sets a key only if it doesn't exist (useful for locks)
func (s *Service) SetNX(ctx context.Context, key string, value interface{}, ttl time.Duration) (bool, error) {
data, err := json.Marshal(value)
if err != nil {
return false, fmt.Errorf("cache marshal: %w", err)
}
ok, err := s.client.SetNX(ctx, key, data, ttl).Result()
if err != nil {
return false, fmt.Errorf("cache setnx: %w", err)
}
return ok, nil
}
// GetSet atomically sets a new value and returns the old value
func (s *Service) GetSet(ctx context.Context, key string, newValue interface{}, target interface{}) error {
data, err := json.Marshal(newValue)
if err != nil {
return fmt.Errorf("cache marshal: %w", err)
}
oldData, err := s.client.GetSet(ctx, key, data).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return ErrCacheMiss
}
return fmt.Errorf("cache getset: %w", err)
}
if err := json.Unmarshal(oldData, target); err != nil {
return fmt.Errorf("cache unmarshal: %w", err)
}
return nil
}
// MGet retrieves multiple keys at once
func (s *Service) MGet(ctx context.Context, keys ...string) ([]interface{}, error) {
if len(keys) == 0 {
return []interface{}{}, nil
}
values, err := s.client.MGet(ctx, keys...).Result()
if err != nil {
return nil, fmt.Errorf("cache mget: %w", err)
}
return values, nil
}
// MSet sets multiple key-value pairs at once
func (s *Service) MSet(ctx context.Context, pairs map[string]interface{}) error {
if len(pairs) == 0 {
return nil
}
// Convert map to slice of interface{} for Redis
args := make([]interface{}, 0, len(pairs)*2)
for key, value := range pairs {
data, err := json.Marshal(value)
if err != nil {
return fmt.Errorf("cache marshal %s: %w", key, err)
}
args = append(args, key, data)
}
if err := s.client.MSet(ctx, args...).Err(); err != nil {
return fmt.Errorf("cache mset: %w", err)
}
return nil
}
// FlushDB clears all keys in the current database (use with caution!)
func (s *Service) FlushDB(ctx context.Context) error {
if err := s.client.FlushDB(ctx).Err(); err != nil {
return fmt.Errorf("cache flush: %w", err)
}
return nil
}
// Keys returns all keys matching a pattern
func (s *Service) Keys(ctx context.Context, pattern string) ([]string, error) {
keys, err := s.client.Keys(ctx, pattern).Result()
if err != nil {
return nil, fmt.Errorf("cache keys: %w", err)
}
return keys, nil
}
// Scan iterates over keys matching a pattern (better than Keys for large datasets)
func (s *Service) Scan(ctx context.Context, pattern string, count int64) ([]string, error) {
var keys []string
var cursor uint64
for {
var batch []string
var err error
batch, cursor, err = s.client.Scan(ctx, cursor, pattern, count).Result()
if err != nil {
return nil, fmt.Errorf("cache scan: %w", err)
}
keys = append(keys, batch...)
if cursor == 0 {
break
}
}
return keys, nil
}
// Ping checks if the cache is responsive
func (s *Service) Ping(ctx context.Context) error {
return s.client.Ping(ctx).Err()
}
// Close closes the cache connection
func (s *Service) Close() error {
return s.client.Close()
}
// Client returns the underlying Redis client for advanced operations
func (s *Service) Client() *redis.Client {
return s.client
}
+205
View File
@@ -0,0 +1,205 @@
package cache
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
)
// SessionData represents cached session information
type SessionData struct {
SessionID string `json:"sessionId"`
UserID string `json:"userId"`
RefreshToken string `json:"refreshToken"`
UserAgent string `json:"userAgent"`
IP string `json:"ip"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
}
// SessionCache provides session caching operations
type SessionCache struct {
service *Service
keys *KeyBuilder
}
// NewSessionCache creates a new session cache
func NewSessionCache(service *Service, namespace string) *SessionCache {
return &SessionCache{
service: service,
keys: NewKeyBuilder(namespace),
}
}
// SetSession stores session data in cache
func (sc *SessionCache) SetSession(ctx context.Context, session SessionData) error {
key := sc.keys.SessionKey(session.SessionID)
ttl := time.Until(session.ExpiresAt)
if ttl <= 0 {
return fmt.Errorf("session already expired")
}
return sc.service.Set(ctx, key, session, ttl)
}
// GetSession retrieves session data from cache
func (sc *SessionCache) GetSession(ctx context.Context, sessionID string) (*SessionData, error) {
key := sc.keys.SessionKey(sessionID)
var session SessionData
if err := sc.service.Get(ctx, key, &session); err != nil {
return nil, err
}
return &session, nil
}
// DeleteSession removes session data from cache
func (sc *SessionCache) DeleteSession(ctx context.Context, sessionID string) error {
key := sc.keys.SessionKey(sessionID)
return sc.service.Delete(ctx, key)
}
// GetSessionByRefreshToken retrieves session by refresh token
// Note: This requires scanning, which is slower. Consider using a secondary index.
func (sc *SessionCache) GetSessionByRefreshToken(ctx context.Context, refreshToken string) (*SessionData, error) {
pattern := sc.keys.Build(PrefixSession, "*")
keys, err := sc.service.Keys(ctx, pattern)
if err != nil {
return nil, err
}
for _, key := range keys {
var session SessionData
if err := sc.service.Get(ctx, key, &session); err != nil {
continue
}
if session.RefreshToken == refreshToken {
return &session, nil
}
}
return nil, ErrCacheMiss
}
// ExtendSession extends the TTL of a session
func (sc *SessionCache) ExtendSession(ctx context.Context, sessionID string, duration time.Duration) error {
key := sc.keys.SessionKey(sessionID)
return sc.service.Expire(ctx, key, duration)
}
// UserData represents cached user information
type UserData struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
CachedAt time.Time `json:"cachedAt"`
}
// SetUser stores user data in cache
func (sc *SessionCache) SetUser(ctx context.Context, user UserData) error {
key := sc.keys.UserKey(user.ID)
return sc.service.Set(ctx, key, user, TTLUser)
}
// GetUser retrieves user data from cache
func (sc *SessionCache) GetUser(ctx context.Context, userID string) (*UserData, error) {
key := sc.keys.UserKey(userID)
var user UserData
if err := sc.service.Get(ctx, key, &user); err != nil {
return nil, err
}
return &user, nil
}
// DeleteUser removes user data from cache
func (sc *SessionCache) DeleteUser(ctx context.Context, userID string) error {
key := sc.keys.UserKey(userID)
return sc.service.Delete(ctx, key)
}
// InvalidateUserSessions removes all sessions for a user
func (sc *SessionCache) InvalidateUserSessions(ctx context.Context, userID string) error {
pattern := sc.keys.Build(PrefixSession, "*")
keys, err := sc.service.Keys(ctx, pattern)
if err != nil {
return err
}
toDelete := make([]string, 0)
for _, key := range keys {
var session SessionData
if err := sc.service.Get(ctx, key, &session); err != nil {
continue
}
if session.UserID == userID {
toDelete = append(toDelete, key)
}
}
if len(toDelete) > 0 {
return sc.service.Delete(ctx, toDelete...)
}
return nil
}
// RateLimitCheck checks if an action is rate limited
func (sc *SessionCache) RateLimitCheck(ctx context.Context, identifier, action string, limit int64, window time.Duration) (bool, error) {
key := sc.keys.RateLimitKey(identifier, action)
count, err := sc.service.Increment(ctx, key)
if err != nil {
return false, err
}
// Set expiry on first increment
if count == 1 {
if err := sc.service.Expire(ctx, key, window); err != nil {
return false, err
}
}
return count <= limit, nil
}
// AcquireLock attempts to acquire a distributed lock
func (sc *SessionCache) AcquireLock(ctx context.Context, resource string, ttl time.Duration) (string, bool, error) {
lockID := uuid.New().String()
key := sc.keys.LockKey(resource)
acquired, err := sc.service.SetNX(ctx, key, lockID, ttl)
if err != nil {
return "", false, err
}
return lockID, acquired, nil
}
// ReleaseLock releases a distributed lock
func (sc *SessionCache) ReleaseLock(ctx context.Context, resource, lockID string) error {
key := sc.keys.LockKey(resource)
// Verify we own the lock before deleting
var storedLockID string
if err := sc.service.Get(ctx, key, &storedLockID); err != nil {
if err == ErrCacheMiss {
return nil // Lock already released
}
return err
}
if storedLockID != lockID {
return fmt.Errorf("lock owned by different process")
}
return sc.service.Delete(ctx, key)
}
@@ -0,0 +1,253 @@
package igdb
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/services/catalog"
)
var ErrIGDBCredentialsMissing = errors.New("igdb client credentials missing")
type Client struct {
cfg config.IGDBConfig
httpClient *http.Client
mu sync.Mutex
accessToken string
accessTokenExp time.Time
}
type tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
TokenType string `json:"token_type"`
}
type gameResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Summary string `json:"summary"`
FirstReleaseDate int64 `json:"first_release_date"`
Rating float64 `json:"rating"`
Genres []struct {
Name string `json:"name"`
} `json:"genres"`
Platforms []struct {
Name string `json:"name"`
} `json:"platforms"`
}
func NewClient(cfg config.IGDBConfig) *Client {
return &Client{
cfg: cfg,
httpClient: &http.Client{
Timeout: 12 * time.Second,
},
}
}
func (c *Client) Enabled() bool {
return strings.TrimSpace(c.cfg.ClientID) != "" && strings.TrimSpace(c.cfg.ClientSecret) != ""
}
func (c *Client) SearchGames(ctx context.Context, query string, limit int) ([]catalog.MediaItem, error) {
if !c.Enabled() {
return nil, ErrIGDBCredentialsMissing
}
cleanQuery := strings.TrimSpace(query)
if cleanQuery == "" {
return []catalog.MediaItem{}, nil
}
if limit < 1 {
limit = 12
}
token, err := c.token(ctx)
if err != nil {
return nil, err
}
body := fmt.Sprintf(
"fields id,name,summary,first_release_date,rating,genres.name,platforms.name; search %q; limit %d;",
cleanQuery,
limit,
)
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
strings.TrimRight(c.cfg.BaseURL, "/")+"/games",
strings.NewReader(body),
)
if err != nil {
return nil, err
}
request.Header.Set("Client-ID", c.cfg.ClientID)
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("Accept", "application/json")
request.Header.Set("Content-Type", "text/plain")
response, err := c.httpClient.Do(request)
if err != nil {
return nil, err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
message, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return nil, fmt.Errorf("igdb search failed: %s", strings.TrimSpace(string(message)))
}
var payload []gameResponse
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return nil, err
}
items := make([]catalog.MediaItem, 0, len(payload))
for _, game := range payload {
title := strings.TrimSpace(game.Name)
if title == "" {
continue
}
overview := strings.TrimSpace(game.Summary)
if overview == "" {
overview = fmt.Sprintf("%s is available through the live IGDB provider search.", title)
}
items = append(items, catalog.MediaItem{
ID: int(game.ID) + 900000,
Provider: catalog.MediaProviderIGDB,
ProviderID: int(game.ID),
Title: title,
Overview: overview,
Type: catalog.MediaTypeGame,
ReleaseDate: unixDate(game.FirstReleaseDate),
Genres: genreNames(game.Genres),
Platforms: platformNames(game.Platforms),
Rating: normalizeRating(game.Rating),
RuntimeMinutes: 0,
ArtworkKey: fmt.Sprintf("igdb-%d", game.ID),
})
}
return items, nil
}
func (c *Client) token(ctx context.Context) (string, error) {
c.mu.Lock()
if c.accessToken != "" && time.Now().Before(c.accessTokenExp.Add(-1*time.Minute)) {
token := c.accessToken
c.mu.Unlock()
return token, nil
}
c.mu.Unlock()
form := url.Values{}
form.Set("client_id", c.cfg.ClientID)
form.Set("client_secret", c.cfg.ClientSecret)
form.Set("grant_type", "client_credentials")
request, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
c.cfg.TokenURL,
strings.NewReader(form.Encode()),
)
if err != nil {
return "", err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, err := c.httpClient.Do(request)
if err != nil {
return "", err
}
defer response.Body.Close()
if response.StatusCode >= http.StatusBadRequest {
message, _ := io.ReadAll(io.LimitReader(response.Body, 2048))
return "", fmt.Errorf("igdb token request failed: %s", strings.TrimSpace(string(message)))
}
var payload tokenResponse
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
return "", err
}
if payload.AccessToken == "" {
return "", errors.New("igdb token response missing access token")
}
c.mu.Lock()
c.accessToken = payload.AccessToken
c.accessTokenExp = time.Now().Add(time.Duration(payload.ExpiresIn) * time.Second)
token := c.accessToken
c.mu.Unlock()
return token, nil
}
func unixDate(value int64) string {
if value <= 0 {
return ""
}
return time.Unix(value, 0).UTC().Format("2006-01-02")
}
func normalizeRating(value float64) float64 {
if value > 10 {
return value / 10
}
return value
}
func genreNames(values []struct {
Name string `json:"name"`
}) []string {
if len(values) == 0 {
return []string{}
}
names := make([]string, 0, len(values))
for _, value := range values {
name := strings.TrimSpace(value.Name)
if name != "" {
names = append(names, name)
}
}
return names
}
func platformNames(values []struct {
Name string `json:"name"`
}) []string {
if len(values) == 0 {
return []string{}
}
names := make([]string, 0, len(values))
for _, value := range values {
name := strings.TrimSpace(value.Name)
if name != "" {
names = append(names, name)
}
}
return names
}
@@ -0,0 +1,55 @@
package tmdb
import (
"context"
"errors"
"net/http"
"time"
"github.com/tdvorak/seen/backend/internal/config"
)
var ErrTMDBAPIKeyMissing = errors.New("tmdb api key missing")
type Client struct {
apiKey string
baseURL string
httpClient *http.Client
}
func NewClient(cfg config.TMDBConfig) *Client {
return &Client{
apiKey: cfg.APIKey,
baseURL: cfg.BaseURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
func (c *Client) Validate(ctx context.Context) error {
if c.apiKey == "" {
return ErrTMDBAPIKeyMissing
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/configuration", nil)
if err != nil {
return err
}
query := request.URL.Query()
query.Set("api_key", c.apiKey)
request.URL.RawQuery = query.Encode()
response, err := c.httpClient.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode >= 400 {
return errors.New("tmdb validation request failed")
}
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package middleware
import (
"net/http"
"strconv"
"slices"
"strings"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/config"
)
func CORS(cfg config.CORSConfig) gin.HandlerFunc {
allowedOrigins := normalizeHeaderValues(cfg.AllowedOrigins)
allowedMethods := normalizeHeaderValues(cfg.AllowedMethods)
allowedHeaders := normalizeHeaderValues(cfg.AllowedHeaders)
exposedHeaders := normalizeHeaderValues(cfg.ExposedHeaders)
allowAnyOrigin := slices.Contains(allowedOrigins, "*")
allowedMethodsValue := strings.Join(allowedMethods, ", ")
allowedHeadersValue := strings.Join(allowedHeaders, ", ")
exposedHeadersValue := strings.Join(exposedHeaders, ", ")
return func(c *gin.Context) {
origin := strings.TrimSpace(c.GetHeader("Origin"))
if origin == "" {
c.Next()
return
}
if !allowAnyOrigin && !slices.Contains(allowedOrigins, origin) {
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusForbidden)
return
}
c.Next()
return
}
if allowAnyOrigin && !cfg.AllowCredentials {
c.Header("Access-Control-Allow-Origin", "*")
} else {
c.Header("Access-Control-Allow-Origin", origin)
c.Header("Vary", "Origin")
}
c.Header("Access-Control-Allow-Methods", allowedMethodsValue)
c.Header("Access-Control-Allow-Headers", allowedHeadersValue)
if exposedHeadersValue != "" {
c.Header("Access-Control-Expose-Headers", exposedHeadersValue)
}
if cfg.AllowCredentials {
c.Header("Access-Control-Allow-Credentials", "true")
}
if cfg.MaxAge > 0 {
c.Header("Access-Control-Max-Age", formatSeconds(cfg.MaxAge.Seconds()))
}
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(http.StatusNoContent)
return
}
c.Next()
}
}
func normalizeHeaderValues(values []string) []string {
items := make([]string, 0, len(values))
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
continue
}
items = append(items, trimmed)
}
return items
}
func formatSeconds(value float64) string {
return strconv.Itoa(int(value))
}
+64
View File
@@ -0,0 +1,64 @@
package middleware
import (
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
const requestIDHeader = "X-Request-ID"
func RequestID() gin.HandlerFunc {
return func(c *gin.Context) {
requestID := c.GetHeader(requestIDHeader)
if requestID == "" {
requestID = uuid.NewString()
}
c.Set("request_id", requestID)
c.Header(requestIDHeader, requestID)
c.Next()
}
}
func AccessLog(log *zap.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
start := time.Now()
c.Next()
requestID, _ := c.Get("request_id")
log.Info("request",
zap.String("method", c.Request.Method),
zap.String("path", c.FullPath()),
zap.Int("status", c.Writer.Status()),
zap.Duration("duration", time.Since(start)),
zap.String("client_ip", c.ClientIP()),
zap.String("request_id", toString(requestID)),
)
}
}
func Recovery(log *zap.Logger) gin.HandlerFunc {
return gin.CustomRecovery(func(c *gin.Context, recovered any) {
requestID, _ := c.Get("request_id")
log.Error("panic recovered",
zap.Any("panic", recovered),
zap.String("request_id", toString(requestID)),
)
c.AbortWithStatusJSON(500, gin.H{
"error": "internal server error",
"requestId": toString(requestID),
})
})
}
func toString(value any) string {
typed, ok := value.(string)
if !ok {
return ""
}
return typed
}
@@ -0,0 +1,154 @@
package postgres
import (
"context"
"errors"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/domain"
)
var ErrUserAlreadyExists = errors.New("user already exists")
type AuthRepository struct {
pool *pgxpool.Pool
}
func NewAuthRepository(pool *pgxpool.Pool) *AuthRepository {
return &AuthRepository{pool: pool}
}
func (r *AuthRepository) CreateUser(ctx context.Context, user domain.User) error {
_, err := r.pool.Exec(
ctx,
`INSERT INTO users (id, email, display_name, role, password_hash, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
user.ID,
strings.ToLower(strings.TrimSpace(user.Email)),
user.DisplayName,
user.Role,
user.PasswordHash,
user.CreatedAt,
user.UpdatedAt,
)
if err != nil && strings.Contains(err.Error(), "duplicate key") {
return ErrUserAlreadyExists
}
return err
}
func (r *AuthRepository) FindUserByEmail(ctx context.Context, email string) (*domain.User, error) {
row := r.pool.QueryRow(
ctx,
`SELECT id, email, display_name, role, password_hash, created_at, updated_at
FROM users
WHERE email = $1`,
strings.ToLower(strings.TrimSpace(email)),
)
var user domain.User
if err := row.Scan(
&user.ID,
&user.Email,
&user.DisplayName,
&user.Role,
&user.PasswordHash,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *AuthRepository) FindUserByID(ctx context.Context, userID uuid.UUID) (*domain.User, error) {
row := r.pool.QueryRow(
ctx,
`SELECT id, email, display_name, role, password_hash, created_at, updated_at
FROM users
WHERE id = $1`,
userID,
)
var user domain.User
if err := row.Scan(
&user.ID,
&user.Email,
&user.DisplayName,
&user.Role,
&user.PasswordHash,
&user.CreatedAt,
&user.UpdatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &user, nil
}
func (r *AuthRepository) CreateSession(ctx context.Context, session domain.Session) error {
_, err := r.pool.Exec(
ctx,
`INSERT INTO sessions (id, user_id, refresh_token, user_agent, ip, expires_at, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
session.ID,
session.UserID,
session.RefreshToken,
session.UserAgent,
session.IP,
session.ExpiresAt,
session.CreatedAt,
)
return err
}
func (r *AuthRepository) FindSessionByRefreshToken(ctx context.Context, refreshToken string) (*domain.Session, error) {
row := r.pool.QueryRow(
ctx,
`SELECT id, user_id, refresh_token, user_agent, ip, expires_at, revoked_at, created_at
FROM sessions
WHERE refresh_token = $1`,
refreshToken,
)
var session domain.Session
if err := row.Scan(
&session.ID,
&session.UserID,
&session.RefreshToken,
&session.UserAgent,
&session.IP,
&session.ExpiresAt,
&session.RevokedAt,
&session.CreatedAt,
); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, nil
}
return nil, err
}
return &session, nil
}
func (r *AuthRepository) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
_, err := r.pool.Exec(
ctx,
`UPDATE sessions
SET revoked_at = now()
WHERE id = $1`,
sessionID,
)
return err
}
@@ -0,0 +1,701 @@
package postgres
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/services/catalog"
)
const listDiscoverRowsSQL = `
WITH matched AS (
SELECT
ds.kind,
ds.title AS section_title,
ds.subtitle AS section_subtitle,
ds.display_order,
dsi.position AS section_position,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
FROM discover_sections ds
JOIN discover_section_items dsi ON dsi.section_kind = ds.kind
JOIN media_items m ON m.id = dsi.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE
(
$1::text = ''
OR LOWER(m.title) LIKE '%' || LOWER($1::text) || '%'
OR LOWER(m.overview) LIKE '%' || LOWER($1::text) || '%'
OR EXISTS (
SELECT 1
FROM media_genres qmg
JOIN genres qg ON qg.id = qmg.genre_id
WHERE qmg.media_id = m.id
AND LOWER(qg.name) LIKE '%' || LOWER($1::text) || '%'
)
)
AND (
$2::text = ''
OR EXISTS (
SELECT 1
FROM media_genres gmg
JOIN genres gg ON gg.id = gmg.genre_id
WHERE gmg.media_id = m.id
AND LOWER(gg.name) = LOWER($2::text)
)
)
AND (
$3::text = ''
OR LOWER($3::text) = 'all'
OR m.media_type = LOWER($3::text)
)
GROUP BY
ds.kind,
ds.title,
ds.subtitle,
ds.display_order,
dsi.position,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key
,
m.platforms
), ranked AS (
SELECT
*,
row_number() OVER (PARTITION BY kind ORDER BY section_position ASC) AS row_num
FROM matched
)
SELECT
kind,
section_title,
section_subtitle,
display_order,
section_position,
id,
provider,
provider_id,
title,
overview,
media_type,
release_date,
rating,
runtime_minutes,
artwork_key,
platforms,
genres
FROM ranked
WHERE row_num > $4::int
AND row_num <= ($4::int + $5::int)
ORDER BY display_order ASC, section_position ASC;
`
const listSectionItemsByKindSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
FROM discover_section_items dsi
JOIN media_items m ON m.id = dsi.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE dsi.section_kind = $1::text
GROUP BY
dsi.position,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY dsi.position ASC
LIMIT $2::int;
`
const searchMediaItemsSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres
FROM media_items m
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE
(
$1::text = ''
OR LOWER(m.title) LIKE '%' || LOWER($1::text) || '%'
OR LOWER(m.overview) LIKE '%' || LOWER($1::text) || '%'
OR EXISTS (
SELECT 1
FROM media_genres qmg
JOIN genres qg ON qg.id = qmg.genre_id
WHERE qmg.media_id = m.id
AND LOWER(qg.name) LIKE '%' || LOWER($1::text) || '%'
)
)
AND (
$2::text = ''
OR EXISTS (
SELECT 1
FROM media_genres gmg
JOIN genres gg ON gg.id = gmg.genre_id
WHERE gmg.media_id = m.id
AND LOWER(gg.name) = LOWER($2::text)
)
)
AND (
$3::text = ''
OR LOWER($3::text) = 'all'
OR m.media_type = LOWER($3::text)
)
GROUP BY
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY m.rating DESC, m.release_date DESC, m.title ASC
LIMIT 50;
`
const listUserWatchLaterSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres,
uwl.created_at
FROM user_watch_later uwl
JOIN media_items m ON m.id = uwl.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE uwl.user_id = $1::uuid
GROUP BY
uwl.created_at,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY uwl.created_at DESC;
`
const addUserWatchLaterSQL = `
INSERT INTO user_watch_later (user_id, media_id)
VALUES ($1::uuid, $2::bigint)
ON CONFLICT (user_id, media_id) DO NOTHING;
`
const removeUserWatchLaterSQL = `
DELETE FROM user_watch_later
WHERE user_id = $1::uuid
AND media_id = $2::bigint;
`
const mediaExistsSQL = `
SELECT EXISTS(SELECT 1 FROM media_items WHERE id = $1::bigint);
`
const listContinueWatchingSQL = `
SELECT
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date::text AS release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms,
COALESCE(array_agg(g.name ORDER BY mg.position) FILTER (WHERE g.name IS NOT NULL), '{}'::text[]) AS genres,
up.season_number,
up.episode_number,
up.progress_percent,
up.last_watched_at
FROM user_progress up
JOIN media_items m ON m.id = up.media_id
LEFT JOIN media_genres mg ON mg.media_id = m.id
LEFT JOIN genres g ON g.id = mg.genre_id
WHERE up.user_id = $1::uuid
AND up.progress_percent > 0
AND up.progress_percent < 100
GROUP BY
up.last_watched_at,
up.season_number,
up.episode_number,
up.progress_percent,
m.id,
m.provider,
m.provider_id,
m.title,
m.overview,
m.media_type,
m.release_date,
m.rating,
m.runtime_minutes,
m.artwork_key,
m.platforms
ORDER BY up.last_watched_at DESC
LIMIT $2::int;
`
const upsertUserProgressSQL = `
INSERT INTO user_progress (
user_id,
media_id,
season_number,
episode_number,
progress_percent,
last_watched_at,
created_at,
updated_at
) VALUES ($1::uuid, $2::bigint, $3::int, $4::int, $5::int, NOW(), NOW(), NOW())
ON CONFLICT (user_id, media_id, season_number, episode_number)
DO UPDATE SET
progress_percent = EXCLUDED.progress_percent,
last_watched_at = NOW(),
updated_at = NOW();
`
type CatalogRepository struct {
pool *pgxpool.Pool
}
func NewCatalogRepository(pool *pgxpool.Pool) *CatalogRepository {
return &CatalogRepository{pool: pool}
}
type scannedMediaItem struct {
id int64
provider string
providerID int64
title string
overview string
mediaType string
releaseDate string
rating float64
runtimeMinutes int32
artworkKey string
platforms []string
genres []string
}
func (item scannedMediaItem) toCatalog() catalog.MediaItem {
return catalog.MediaItem{
ID: int(item.id),
Provider: catalog.MediaProvider(item.provider),
ProviderID: int(item.providerID),
Title: item.title,
Overview: item.overview,
Type: catalog.MediaType(item.mediaType),
ReleaseDate: item.releaseDate,
Genres: cloneStrings(item.genres),
Platforms: cloneStrings(item.platforms),
Rating: item.rating,
RuntimeMinutes: int(item.runtimeMinutes),
ArtworkKey: item.artworkKey,
}
}
func (r *CatalogRepository) Discover(
ctx context.Context,
params catalog.DiscoverParams,
) ([]catalog.DiscoverSection, error) {
offset := (params.Page - 1) * params.PageSize
rows, err := r.pool.Query(
ctx,
listDiscoverRowsSQL,
strings.TrimSpace(params.Query),
strings.TrimSpace(params.Genre),
strings.TrimSpace(params.MediaType),
offset,
params.PageSize,
)
if err != nil {
return nil, err
}
defer rows.Close()
sections := make([]catalog.DiscoverSection, 0)
sectionIndex := make(map[string]int)
for rows.Next() {
var kind string
var sectionTitle string
var sectionSubtitle string
var displayOrder int16
var sectionPosition int16
var item scannedMediaItem
if err := rows.Scan(
&kind,
&sectionTitle,
&sectionSubtitle,
&displayOrder,
&sectionPosition,
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
); err != nil {
return nil, err
}
_ = displayOrder
_ = sectionPosition
idx, exists := sectionIndex[kind]
if !exists {
idx = len(sections)
sectionIndex[kind] = idx
sections = append(sections, catalog.DiscoverSection{
Kind: kind,
Title: sectionTitle,
Subtitle: sectionSubtitle,
Items: []catalog.MediaItem{},
})
}
sections[idx].Items = append(sections[idx].Items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return sections, nil
}
func (r *CatalogRepository) SectionItems(
ctx context.Context,
kind string,
limit int,
) ([]catalog.MediaItem, error) {
rows, err := r.pool.Query(ctx, listSectionItemsByKindSQL, strings.TrimSpace(kind), limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.MediaItem, 0, limit)
for rows.Next() {
var item scannedMediaItem
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
); err != nil {
return nil, err
}
items = append(items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) SearchMedia(
ctx context.Context,
params catalog.SearchParams,
) ([]catalog.MediaItem, error) {
query := strings.TrimSpace(params.Query)
if query == "" {
return []catalog.MediaItem{}, nil
}
rows, err := r.pool.Query(
ctx,
searchMediaItemsSQL,
query,
strings.TrimSpace(params.Genre),
strings.TrimSpace(params.MediaType),
)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.MediaItem, 0, 32)
for rows.Next() {
var item scannedMediaItem
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
); err != nil {
return nil, err
}
items = append(items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) ListWatchLater(
ctx context.Context,
userID uuid.UUID,
) ([]catalog.MediaItem, error) {
rows, err := r.pool.Query(ctx, listUserWatchLaterSQL, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.MediaItem, 0, 8)
for rows.Next() {
var item scannedMediaItem
var createdAt time.Time
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
&createdAt,
); err != nil {
return nil, err
}
_ = createdAt
items = append(items, item.toCatalog())
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) AddWatchLater(
ctx context.Context,
userID uuid.UUID,
mediaID int,
) error {
if err := r.ensureMediaExists(ctx, mediaID); err != nil {
return err
}
_, err := r.pool.Exec(ctx, addUserWatchLaterSQL, userID, mediaID)
return err
}
func (r *CatalogRepository) RemoveWatchLater(
ctx context.Context,
userID uuid.UUID,
mediaID int,
) error {
_, err := r.pool.Exec(ctx, removeUserWatchLaterSQL, userID, mediaID)
return err
}
func (r *CatalogRepository) ListContinueWatching(
ctx context.Context,
userID uuid.UUID,
limit int,
) ([]catalog.ContinueWatchingItem, error) {
rows, err := r.pool.Query(ctx, listContinueWatchingSQL, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := make([]catalog.ContinueWatchingItem, 0, limit)
for rows.Next() {
var item scannedMediaItem
var seasonNumber int32
var episodeNumber int32
var progressPercent int32
var lastWatchedAt time.Time
if err := rows.Scan(
&item.id,
&item.provider,
&item.providerID,
&item.title,
&item.overview,
&item.mediaType,
&item.releaseDate,
&item.rating,
&item.runtimeMinutes,
&item.artworkKey,
&item.platforms,
&item.genres,
&seasonNumber,
&episodeNumber,
&progressPercent,
&lastWatchedAt,
); err != nil {
return nil, err
}
items = append(items, catalog.ContinueWatchingItem{
Item: item.toCatalog(),
Progress: catalog.EpisodeProgress{
ItemID: int(item.id),
SeasonNumber: int(seasonNumber),
EpisodeNumber: int(episodeNumber),
ProgressPercent: int(progressPercent),
LastWatchedAt: lastWatchedAt.UTC().Format(time.RFC3339),
},
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
func (r *CatalogRepository) UpsertProgress(
ctx context.Context,
userID uuid.UUID,
input catalog.ProgressUpdateInput,
) error {
if err := r.ensureMediaExists(ctx, input.MediaID); err != nil {
return err
}
_, err := r.pool.Exec(
ctx,
upsertUserProgressSQL,
userID,
input.MediaID,
input.SeasonNumber,
input.EpisodeNumber,
input.ProgressPercent,
)
return err
}
func (r *CatalogRepository) ensureMediaExists(ctx context.Context, mediaID int) error {
var exists bool
if err := r.pool.QueryRow(ctx, mediaExistsSQL, mediaID).Scan(&exists); err != nil {
return err
}
if !exists {
return catalog.ErrMediaNotFound
}
return nil
}
func cloneStrings(values []string) []string {
if len(values) == 0 {
return []string{}
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
@@ -0,0 +1,506 @@
package postgres
import (
"context"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/services/download"
)
const createDownloadJobSQL = `
INSERT INTO download_jobs (
user_id,
source_type,
source,
title,
status,
queue_position,
progress_percent,
bytes_total,
bytes_downloaded,
download_speed_mbps,
eta_seconds,
error_message,
retry_count,
created_at,
updated_at
) VALUES (
$1::uuid,
$2::text,
$3::text,
COALESCE(NULLIF($4::text, ''), $3::text),
'queued',
NULL,
0,
0,
0,
0,
NULL,
'',
0,
NOW(),
NOW()
)
RETURNING
id::text,
user_id::text,
source_type,
source,
title,
status,
COALESCE(queue_position, 0),
progress_percent,
bytes_total,
bytes_downloaded,
download_speed_mbps,
COALESCE(eta_seconds, 0),
error_message,
retry_count,
created_at,
updated_at;
`
const listDownloadJobsSQL = `
SELECT
id::text,
user_id::text,
source_type,
source,
title,
status,
COALESCE(queue_position, 0),
progress_percent,
bytes_total,
bytes_downloaded,
download_speed_mbps,
COALESCE(eta_seconds, 0),
error_message,
retry_count,
created_at,
updated_at,
COALESCE(started_at, NOW()),
COALESCE(completed_at, NOW()),
COALESCE(cancelled_at, NOW())
FROM download_jobs
WHERE user_id = $1::uuid
AND ($2::text = '' OR status = $2::text)
ORDER BY updated_at DESC, created_at DESC
LIMIT $3::int
OFFSET $4::int;
`
const getDownloadJobByIDSQL = `
SELECT
id::text,
user_id::text,
source_type,
source,
title,
status,
COALESCE(queue_position, 0),
progress_percent,
bytes_total,
bytes_downloaded,
download_speed_mbps,
COALESCE(eta_seconds, 0),
error_message,
retry_count,
created_at,
updated_at,
COALESCE(started_at, NOW()),
COALESCE(completed_at, NOW()),
COALESCE(cancelled_at, NOW())
FROM download_jobs
WHERE user_id = $1::uuid
AND id = $2::uuid
LIMIT 1;
`
const cancelDownloadJobSQL = `
UPDATE download_jobs
SET
status = 'cancelled',
cancelled_at = NOW(),
updated_at = NOW()
WHERE user_id = $1::uuid
AND id = $2::uuid
RETURNING
id::text,
user_id::text,
source_type,
source,
title,
status,
COALESCE(queue_position, 0),
progress_percent,
bytes_total,
bytes_downloaded,
download_speed_mbps,
COALESCE(eta_seconds, 0),
error_message,
retry_count,
created_at,
updated_at,
COALESCE(started_at, NOW()),
COALESCE(completed_at, NOW()),
COALESCE(cancelled_at, NOW());
`
const appendDownloadEventSQL = `
INSERT INTO download_events (
job_id,
status,
message,
progress_percent,
payload,
created_at
) VALUES (
$1::uuid,
$2::text,
$3::text,
$4::int,
$5::jsonb,
NOW()
);
`
const updateDownloadJobSQL = `
UPDATE download_jobs
SET
status = $3::text,
progress_percent = $4::int,
bytes_total = $5::bigint,
bytes_downloaded = $6::bigint,
download_speed_mbps = $7::double precision,
eta_seconds = CASE WHEN $8::int <= 0 THEN NULL ELSE $8::int END,
error_message = $9::text,
retry_count = $10::smallint,
started_at = CASE
WHEN $3::text IN ('preparing', 'downloading') AND started_at IS NULL THEN NOW()
ELSE started_at
END,
completed_at = CASE
WHEN $3::text = 'completed' THEN NOW()
ELSE completed_at
END,
cancelled_at = CASE
WHEN $3::text = 'cancelled' THEN NOW()
ELSE cancelled_at
END,
updated_at = NOW()
WHERE user_id = $1::uuid
AND id = $2::uuid
RETURNING
id::text,
user_id::text,
source_type,
source,
title,
status,
COALESCE(queue_position, 0),
progress_percent,
bytes_total,
bytes_downloaded,
download_speed_mbps,
COALESCE(eta_seconds, 0),
error_message,
retry_count,
created_at,
updated_at,
COALESCE(started_at, NOW()),
COALESCE(completed_at, NOW()),
COALESCE(cancelled_at, NOW());
`
const listDownloadEventsSQL = `
SELECT
id,
job_id::text,
status,
message,
progress_percent,
payload::text,
created_at
FROM download_events
WHERE job_id = $1::uuid
AND ($2::timestamptz IS NULL OR created_at > $2::timestamptz)
ORDER BY created_at DESC
LIMIT $3::int;
`
type DownloadRepository struct {
pool *pgxpool.Pool
}
func NewDownloadRepository(pool *pgxpool.Pool) *DownloadRepository {
return &DownloadRepository{pool: pool}
}
func (r *DownloadRepository) CreateJob(
ctx context.Context,
userID uuid.UUID,
input download.CreateInput,
) (download.Job, error) {
var job download.Job
err := r.pool.QueryRow(
ctx,
createDownloadJobSQL,
userID,
input.SourceType,
input.Source,
input.Title,
).Scan(
&job.ID,
&job.UserID,
&job.SourceType,
&job.Source,
&job.Title,
&job.Status,
&job.QueuePosition,
&job.ProgressPercent,
&job.BytesTotal,
&job.BytesDownloaded,
&job.DownloadSpeedMbps,
&job.EtaSeconds,
&job.ErrorMessage,
&job.RetryCount,
&job.CreatedAt,
&job.UpdatedAt,
)
if err != nil {
return download.Job{}, err
}
return job, nil
}
func (r *DownloadRepository) ListJobs(
ctx context.Context,
userID uuid.UUID,
params download.ListParams,
) ([]download.Job, error) {
rows, err := r.pool.Query(
ctx,
listDownloadJobsSQL,
userID,
params.Status,
params.Limit,
params.Offset,
)
if err != nil {
return nil, err
}
defer rows.Close()
jobs := make([]download.Job, 0, params.Limit)
for rows.Next() {
var job download.Job
if err := rows.Scan(
&job.ID,
&job.UserID,
&job.SourceType,
&job.Source,
&job.Title,
&job.Status,
&job.QueuePosition,
&job.ProgressPercent,
&job.BytesTotal,
&job.BytesDownloaded,
&job.DownloadSpeedMbps,
&job.EtaSeconds,
&job.ErrorMessage,
&job.RetryCount,
&job.CreatedAt,
&job.UpdatedAt,
&job.StartedAt,
&job.CompletedAt,
&job.CancelledAt,
); err != nil {
return nil, err
}
jobs = append(jobs, job)
}
return jobs, rows.Err()
}
func (r *DownloadRepository) GetJobByID(
ctx context.Context,
userID uuid.UUID,
jobID string,
) (download.Job, error) {
var job download.Job
err := r.pool.QueryRow(ctx, getDownloadJobByIDSQL, userID, jobID).Scan(
&job.ID,
&job.UserID,
&job.SourceType,
&job.Source,
&job.Title,
&job.Status,
&job.QueuePosition,
&job.ProgressPercent,
&job.BytesTotal,
&job.BytesDownloaded,
&job.DownloadSpeedMbps,
&job.EtaSeconds,
&job.ErrorMessage,
&job.RetryCount,
&job.CreatedAt,
&job.UpdatedAt,
&job.StartedAt,
&job.CompletedAt,
&job.CancelledAt,
)
if err != nil {
return download.Job{}, download.ErrNotFound
}
return job, nil
}
func (r *DownloadRepository) CancelJob(
ctx context.Context,
userID uuid.UUID,
jobID string,
) (download.Job, error) {
var job download.Job
err := r.pool.QueryRow(ctx, cancelDownloadJobSQL, userID, jobID).Scan(
&job.ID,
&job.UserID,
&job.SourceType,
&job.Source,
&job.Title,
&job.Status,
&job.QueuePosition,
&job.ProgressPercent,
&job.BytesTotal,
&job.BytesDownloaded,
&job.DownloadSpeedMbps,
&job.EtaSeconds,
&job.ErrorMessage,
&job.RetryCount,
&job.CreatedAt,
&job.UpdatedAt,
&job.StartedAt,
&job.CompletedAt,
&job.CancelledAt,
)
if err != nil {
return download.Job{}, download.ErrNotFound
}
return job, nil
}
func (r *DownloadRepository) ListEvents(
ctx context.Context,
userID uuid.UUID,
jobID string,
params download.EventParams,
) ([]download.Event, error) {
var after pgtype.Timestamptz
if params.After != "" {
t, err := time.Parse(time.RFC3339, params.After)
if err == nil {
after = pgtype.Timestamptz{
Time: t,
Valid: true,
}
}
}
rows, err := r.pool.Query(ctx, listDownloadEventsSQL, jobID, after, params.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
events := make([]download.Event, 0, params.Limit)
for rows.Next() {
var event download.Event
if err := rows.Scan(
&event.ID,
&event.JobID,
&event.Status,
&event.Message,
&event.ProgressPercent,
&event.Payload,
&event.CreatedAt,
); err != nil {
return nil, err
}
events = append(events, event)
}
return events, rows.Err()
}
func (r *DownloadRepository) AppendEvent(
ctx context.Context,
jobID string,
event download.Event,
) error {
_, err := r.pool.Exec(
ctx,
appendDownloadEventSQL,
jobID,
event.Status,
event.Message,
event.ProgressPercent,
event.Payload,
)
return err
}
func (r *DownloadRepository) UpdateJob(
ctx context.Context,
userID uuid.UUID,
jobID string,
input download.UpdateInput,
) (download.Job, error) {
var job download.Job
err := r.pool.QueryRow(
ctx,
updateDownloadJobSQL,
userID,
jobID,
input.Status,
input.ProgressPercent,
input.BytesTotal,
input.BytesDownloaded,
input.DownloadSpeedMbps,
input.EtaSeconds,
input.ErrorMessage,
input.RetryCount,
).Scan(
&job.ID,
&job.UserID,
&job.SourceType,
&job.Source,
&job.Title,
&job.Status,
&job.QueuePosition,
&job.ProgressPercent,
&job.BytesTotal,
&job.BytesDownloaded,
&job.DownloadSpeedMbps,
&job.EtaSeconds,
&job.ErrorMessage,
&job.RetryCount,
&job.CreatedAt,
&job.UpdatedAt,
&job.StartedAt,
&job.CompletedAt,
&job.CancelledAt,
)
if err != nil {
return download.Job{}, download.ErrNotFound
}
return job, nil
}
@@ -0,0 +1,37 @@
package postgres
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/tdvorak/seen/backend/internal/config"
"go.uber.org/zap"
)
func NewPool(ctx context.Context, cfg config.PostgresConfig, log *zap.Logger) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(cfg.URL)
if err != nil {
return nil, fmt.Errorf("parse postgres url: %w", err)
}
poolConfig.MaxConns = cfg.MaxConns
poolConfig.MinConns = cfg.MinConns
poolConfig.MaxConnIdleTime = 5 * time.Minute
poolConfig.HealthCheckPeriod = 30 * time.Second
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("connect postgres: %w", err)
}
if err := pool.Ping(ctx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping postgres: %w", err)
}
log.Info("postgres connected", zap.Int32("max_conns", cfg.MaxConns), zap.Int32("min_conns", cfg.MinConns))
return pool, nil
}
+34
View File
@@ -0,0 +1,34 @@
package scanner
import (
"context"
"time"
"go.uber.org/zap"
)
type Worker struct {
log *zap.Logger
}
func NewWorker(log *zap.Logger) *Worker {
return &Worker{log: log}
}
func (w *Worker) Name() string {
return "library-scanner"
}
func (w *Worker) Start(ctx context.Context) error {
ticker := time.NewTicker(45 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
w.log.Debug("scanner heartbeat")
}
}
}
+256
View File
@@ -0,0 +1,256 @@
package auth
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/repositories/postgres"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidInput = errors.New("invalid input")
ErrInvalidCredentials = errors.New("invalid credentials")
ErrEmailTaken = errors.New("email already exists")
ErrInvalidSession = errors.New("invalid session")
ErrInvalidToken = errors.New("invalid token")
)
type Repository interface {
CreateUser(ctx context.Context, user domain.User) error
FindUserByEmail(ctx context.Context, email string) (*domain.User, error)
FindUserByID(ctx context.Context, userID uuid.UUID) (*domain.User, error)
CreateSession(ctx context.Context, session domain.Session) error
FindSessionByRefreshToken(ctx context.Context, refreshToken string) (*domain.Session, error)
RevokeSession(ctx context.Context, sessionID uuid.UUID) error
}
type Service struct {
repo Repository
cfg config.AuthConfig
log *zap.Logger
}
type RegisterInput struct {
Email string
Password string
DisplayName string
}
type LoginInput struct {
Email string
Password string
UserAgent string
IP string
}
type RefreshInput struct {
RefreshToken string
UserAgent string
IP string
}
type AuthResult struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt time.Time `json:"expiresAt"`
User domain.User `json:"user"`
}
func NewService(repo Repository, cfg config.AuthConfig, log *zap.Logger) *Service {
return &Service{repo: repo, cfg: cfg, log: log}
}
func (s *Service) Register(ctx context.Context, input RegisterInput) (*AuthResult, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" || len(input.Password) < 8 {
return nil, ErrInvalidInput
}
displayName := strings.TrimSpace(input.DisplayName)
if displayName == "" {
displayName = strings.Split(email, "@")[0]
}
passwordHash, err := bcrypt.GenerateFromPassword([]byte(input.Password), bcrypt.DefaultCost)
if err != nil {
return nil, fmt.Errorf("hash password: %w", err)
}
now := time.Now().UTC()
user := domain.User{
ID: uuid.New(),
Email: email,
DisplayName: displayName,
Role: domain.RoleUser,
PasswordHash: string(passwordHash),
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.CreateUser(ctx, user); err != nil {
if errors.Is(err, postgres.ErrUserAlreadyExists) {
return nil, ErrEmailTaken
}
return nil, fmt.Errorf("create user: %w", err)
}
return s.createTokens(ctx, user, "", "")
}
func (s *Service) Login(ctx context.Context, input LoginInput) (*AuthResult, error) {
email := strings.ToLower(strings.TrimSpace(input.Email))
if email == "" || input.Password == "" {
return nil, ErrInvalidInput
}
user, err := s.repo.FindUserByEmail(ctx, email)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
if user == nil {
return nil, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(input.Password)); err != nil {
return nil, ErrInvalidCredentials
}
return s.createTokens(ctx, *user, input.UserAgent, input.IP)
}
func (s *Service) Refresh(ctx context.Context, input RefreshInput) (*AuthResult, error) {
if strings.TrimSpace(input.RefreshToken) == "" {
return nil, ErrInvalidInput
}
session, err := s.repo.FindSessionByRefreshToken(ctx, input.RefreshToken)
if err != nil {
return nil, fmt.Errorf("find session: %w", err)
}
if session == nil || session.RevokedAt != nil || session.ExpiresAt.Before(time.Now().UTC()) {
return nil, ErrInvalidSession
}
if err := s.repo.RevokeSession(ctx, session.ID); err != nil {
return nil, fmt.Errorf("revoke session: %w", err)
}
user, err := s.repo.FindUserByID(ctx, session.UserID)
if err != nil {
return nil, fmt.Errorf("find user: %w", err)
}
if user == nil {
return nil, ErrInvalidSession
}
user.PasswordHash = ""
return s.createTokens(ctx, *user, input.UserAgent, input.IP)
}
func (s *Service) UserFromAccessToken(ctx context.Context, accessToken string) (*domain.User, error) {
token := strings.TrimSpace(accessToken)
if token == "" {
return nil, ErrInvalidToken
}
parsed, err := jwt.Parse(token, func(token *jwt.Token) (any, error) {
if token.Method != jwt.SigningMethodHS256 {
return nil, ErrInvalidToken
}
return []byte(s.cfg.JWTSecret), nil
})
if err != nil || !parsed.Valid {
return nil, ErrInvalidToken
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return nil, ErrInvalidToken
}
subject, ok := claims["sub"].(string)
if !ok || strings.TrimSpace(subject) == "" {
return nil, ErrInvalidToken
}
userID, err := uuid.Parse(subject)
if err != nil {
return nil, ErrInvalidToken
}
user, err := s.repo.FindUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("find user by token: %w", err)
}
if user == nil {
return nil, ErrInvalidToken
}
user.PasswordHash = ""
return user, nil
}
func (s *Service) createTokens(
ctx context.Context,
user domain.User,
userAgent string,
ip string,
) (*AuthResult, error) {
accessToken, expiresAt, err := s.signAccessToken(user)
if err != nil {
return nil, err
}
session := domain.Session{
ID: uuid.New(),
UserID: user.ID,
RefreshToken: uuid.NewString(),
UserAgent: strings.TrimSpace(userAgent),
IP: strings.TrimSpace(ip),
ExpiresAt: time.Now().UTC().Add(time.Duration(s.cfg.RefreshTokenTTLHours) * time.Hour),
CreatedAt: time.Now().UTC(),
}
if err := s.repo.CreateSession(ctx, session); err != nil {
return nil, fmt.Errorf("create session: %w", err)
}
user.PasswordHash = ""
return &AuthResult{
AccessToken: accessToken,
RefreshToken: session.RefreshToken,
ExpiresAt: expiresAt,
User: user,
}, nil
}
func (s *Service) signAccessToken(user domain.User) (string, time.Time, error) {
expiresAt := time.Now().UTC().Add(time.Duration(s.cfg.AccessTokenTTLMinutes) * time.Minute)
claims := jwt.MapClaims{
"sub": user.ID.String(),
"role": string(user.Role),
"exp": expiresAt.Unix(),
"iat": time.Now().UTC().Unix(),
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
signed, err := token.SignedString([]byte(s.cfg.JWTSecret))
if err != nil {
return "", time.Time{}, fmt.Errorf("sign access token: %w", err)
}
return signed, expiresAt, nil
}
@@ -0,0 +1,165 @@
package auth
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/repositories/postgres"
"go.uber.org/zap"
)
type inMemoryRepo struct {
usersByEmail map[string]domain.User
sessions map[string]domain.Session
}
func newInMemoryRepo() *inMemoryRepo {
return &inMemoryRepo{
usersByEmail: make(map[string]domain.User),
sessions: make(map[string]domain.Session),
}
}
func (r *inMemoryRepo) CreateUser(_ context.Context, user domain.User) error {
if _, exists := r.usersByEmail[user.Email]; exists {
return postgres.ErrUserAlreadyExists
}
r.usersByEmail[user.Email] = user
return nil
}
func (r *inMemoryRepo) FindUserByEmail(_ context.Context, email string) (*domain.User, error) {
user, exists := r.usersByEmail[email]
if !exists {
return nil, nil
}
copy := user
return &copy, nil
}
func (r *inMemoryRepo) FindUserByID(_ context.Context, userID uuid.UUID) (*domain.User, error) {
for _, user := range r.usersByEmail {
if user.ID == userID {
copy := user
return &copy, nil
}
}
return nil, nil
}
func (r *inMemoryRepo) CreateSession(_ context.Context, session domain.Session) error {
r.sessions[session.RefreshToken] = session
return nil
}
func (r *inMemoryRepo) FindSessionByRefreshToken(_ context.Context, refreshToken string) (*domain.Session, error) {
session, exists := r.sessions[refreshToken]
if !exists {
return nil, nil
}
copy := session
return &copy, nil
}
func (r *inMemoryRepo) RevokeSession(_ context.Context, sessionID uuid.UUID) error {
now := time.Now().UTC()
for token, session := range r.sessions {
if session.ID == sessionID {
session.RevokedAt = &now
r.sessions[token] = session
return nil
}
}
return errors.New("session not found")
}
func TestRegisterValidation(t *testing.T) {
svc := NewService(newInMemoryRepo(), config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
_, err := svc.Register(context.Background(), RegisterInput{
Email: "",
Password: "short",
})
if !errors.Is(err, ErrInvalidInput) {
t.Fatalf("expected invalid input error, got %v", err)
}
}
func TestRegisterAndLoginFlow(t *testing.T) {
repo := newInMemoryRepo()
svc := NewService(repo, config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
registered, err := svc.Register(context.Background(), RegisterInput{
Email: "user@example.com",
Password: "password123",
DisplayName: "Seen User",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
if registered.AccessToken == "" || registered.RefreshToken == "" {
t.Fatalf("expected issued tokens")
}
loggedIn, err := svc.Login(context.Background(), LoginInput{
Email: "user@example.com",
Password: "password123",
})
if err != nil {
t.Fatalf("login failed: %v", err)
}
if loggedIn.AccessToken == "" {
t.Fatalf("expected login access token")
}
}
func TestLoginWrongPassword(t *testing.T) {
repo := newInMemoryRepo()
svc := NewService(repo, config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
_, err := svc.Register(context.Background(), RegisterInput{
Email: "user@example.com",
Password: "password123",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
_, err = svc.Login(context.Background(), LoginInput{
Email: "user@example.com",
Password: "wrongpass",
})
if !errors.Is(err, ErrInvalidCredentials) {
t.Fatalf("expected invalid credentials error, got %v", err)
}
}
func TestUserFromAccessToken(t *testing.T) {
repo := newInMemoryRepo()
svc := NewService(repo, config.AuthConfig{AccessTokenTTLMinutes: 10, RefreshTokenTTLHours: 1, JWTSecret: "test"}, zap.NewNop())
authResult, err := svc.Register(context.Background(), RegisterInput{
Email: "user@example.com",
Password: "password123",
})
if err != nil {
t.Fatalf("register failed: %v", err)
}
user, err := svc.UserFromAccessToken(context.Background(), authResult.AccessToken)
if err != nil {
t.Fatalf("user from access token failed: %v", err)
}
if user.Email != "user@example.com" {
t.Fatalf("expected user email, got %s", user.Email)
}
}
@@ -0,0 +1,771 @@
package catalog
import (
"context"
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/google/uuid"
)
type sectionSeed struct {
Kind string
Title string
Subtitle string
ItemIDs []int
}
type Repository interface {
Discover(ctx context.Context, params DiscoverParams) ([]DiscoverSection, error)
SectionItems(ctx context.Context, kind string, limit int) ([]MediaItem, error)
SearchMedia(ctx context.Context, params SearchParams) ([]MediaItem, error)
ListWatchLater(ctx context.Context, userID uuid.UUID) ([]MediaItem, error)
AddWatchLater(ctx context.Context, userID uuid.UUID, mediaID int) error
RemoveWatchLater(ctx context.Context, userID uuid.UUID, mediaID int) error
ListContinueWatching(ctx context.Context, userID uuid.UUID, limit int) ([]ContinueWatchingItem, error)
UpsertProgress(ctx context.Context, userID uuid.UUID, input ProgressUpdateInput) error
}
type GameLookup interface {
SearchGames(ctx context.Context, query string, limit int) ([]MediaItem, error)
}
var (
ErrInvalidInput = errors.New("invalid input")
ErrMediaNotFound = errors.New("media not found")
)
type Service struct {
repo Repository
gameLookup GameLookup
media map[int]MediaItem
allMedia []MediaItem
sections []sectionSeed
dashboard DashboardPayload
continueWatching []ContinueWatchingItem
}
func NewService(repo ...Repository) *Service {
var selected Repository
if len(repo) > 0 {
selected = repo[0]
}
allMedia := []MediaItem{
newMedia(1, MediaProviderTMDB, 10001, "Neon Divide", MediaTypeMovie, []string{"Sci-Fi", "Thriller"}, nil, "2025-05-14", 8.5, 118),
newMedia(2, MediaProviderTMDB, 10002, "Last Light Harbor", MediaTypeShow, []string{"Drama", "Mystery"}, nil, "2024-09-02", 8.1, 52),
newMedia(3, MediaProviderTMDB, 10003, "Orbitline", MediaTypeMovie, []string{"Sci-Fi", "Adventure"}, nil, "2025-02-21", 7.9, 131),
newMedia(4, MediaProviderTMDB, 10004, "Static Bloom", MediaTypeShow, []string{"Comedy", "Drama"}, nil, "2023-11-12", 7.8, 42),
newMedia(5, MediaProviderTMDB, 10005, "Kingdom Ash", MediaTypeMovie, []string{"Fantasy", "Action"}, nil, "2024-12-08", 8.7, 143),
newMedia(6, MediaProviderTMDB, 10006, "Pulse District", MediaTypeShow, []string{"Crime", "Thriller"}, nil, "2025-03-18", 8.0, 48),
newMedia(7, MediaProviderTMDB, 10007, "The Glass Relay", MediaTypeMovie, []string{"Action", "Thriller"}, nil, "2024-07-01", 7.6, 109),
newMedia(8, MediaProviderTMDB, 10008, "Summer in Vanta", MediaTypeShow, []string{"Romance", "Drama"}, nil, "2025-06-30", 7.5, 44),
newMedia(9, MediaProviderTMDB, 10009, "Zero Meridian", MediaTypeMovie, []string{"Sci-Fi", "Action"}, nil, "2026-01-10", 8.9, 127),
newMedia(10, MediaProviderTMDB, 10010, "Northline 13", MediaTypeShow, []string{"Mystery", "Crime"}, nil, "2025-10-19", 8.3, 50),
newMedia(11, MediaProviderTMDB, 10011, "Paper Falcons", MediaTypeMovie, []string{"Adventure", "Family"}, nil, "2024-03-05", 7.4, 101),
newMedia(12, MediaProviderTMDB, 10012, "Hollow Anthem", MediaTypeMovie, []string{"Drama", "Music"}, nil, "2025-08-22", 8.2, 114),
newMedia(13, MediaProviderTMDB, 10013, "Riptide Avenue", MediaTypeShow, []string{"Action", "Drama"}, nil, "2023-04-09", 7.7, 55),
newMedia(14, MediaProviderTMDB, 10014, "Night Air Index", MediaTypeMovie, []string{"Mystery", "Thriller"}, nil, "2026-04-03", 8.4, 122),
newMedia(15, MediaProviderTMDB, 10015, "Shoreline Math", MediaTypeShow, []string{"Comedy", "Family"}, nil, "2024-06-17", 7.3, 37),
newMedia(16, MediaProviderTMDB, 10016, "Arcadia Wire", MediaTypeMovie, []string{"Fantasy", "Drama"}, nil, "2025-12-02", 8.6, 136),
newMedia(17, MediaProviderTMDB, 10017, "Abyss Echo", MediaTypeShow, []string{"Sci-Fi", "Mystery"}, nil, "2025-01-27", 8.8, 53),
newMedia(18, MediaProviderTMDB, 10018, "Delta Murmur", MediaTypeMovie, []string{"Horror", "Thriller"}, nil, "2024-10-29", 7.2, 96),
newMedia(19, MediaProviderTMDB, 10019, "Pine Weather", MediaTypeShow, []string{"Drama", "Romance"}, nil, "2025-04-11", 7.9, 46),
newMedia(20, MediaProviderTMDB, 10020, "Copper Atlas", MediaTypeMovie, []string{"Adventure", "Action"}, nil, "2025-09-09", 8.0, 111),
newMedia(21, MediaProviderTMDB, 10021, "Moonset Terminal", MediaTypeMovie, []string{"Sci-Fi", "Drama"}, nil, "2026-02-14", 8.4, 124),
newMedia(22, MediaProviderTMDB, 10022, "Marble Sea", MediaTypeShow, []string{"Fantasy", "Adventure"}, nil, "2024-01-22", 7.6, 49),
newMedia(23, MediaProviderTMDB, 10023, "Tangent Room", MediaTypeMovie, []string{"Mystery", "Drama"}, nil, "2025-11-03", 8.1, 119),
newMedia(24, MediaProviderTMDB, 10024, "Signal Orchard", MediaTypeShow, []string{"Thriller", "Drama"}, nil, "2025-07-18", 8.2, 51),
newMedia(25, MediaProviderIGDB, 20025, "Star Circuit Zero", MediaTypeGame, []string{"Action", "Racing"}, []string{"PC", "PS5", "Xbox Series X|S"}, "2026-09-18", 8.8, 900),
newMedia(26, MediaProviderIGDB, 20026, "Verdant Protocol", MediaTypeGame, []string{"Strategy", "Simulation"}, []string{"PC"}, "2026-11-06", 8.4, 1260),
newMedia(27, MediaProviderIGDB, 20027, "Mythic Drift", MediaTypeGame, []string{"Racing", "Adventure"}, []string{"PS5", "Xbox Series X|S"}, "2026-07-24", 8.2, 720),
newMedia(28, MediaProviderIGDB, 20028, "Ashen Vale", MediaTypeGame, []string{"RPG", "Adventure"}, []string{"PC", "PS5"}, "2026-05-15", 9.0, 1680),
newMedia(29, MediaProviderIGDB, 20029, "Signal Breaker", MediaTypeGame, []string{"Shooter", "Sci-Fi"}, []string{"PC", "Xbox Series X|S"}, "2026-02-28", 8.1, 840),
newMedia(30, MediaProviderIGDB, 20030, "Luma Forge", MediaTypeGame, []string{"Indie", "Puzzle"}, []string{"Nintendo Switch", "PC"}, "2026-01-16", 8.3, 360),
newMedia(31, MediaProviderIGDB, 20031, "Citadel Dawn", MediaTypeGame, []string{"Strategy", "RPG"}, []string{"PC"}, "2026-03-05", 8.6, 1500),
newMedia(32, MediaProviderIGDB, 20032, "Harbor Tactics", MediaTypeGame, []string{"Strategy", "Simulation"}, []string{"PC"}, "2025-11-21", 7.8, 1080),
newMedia(33, MediaProviderIGDB, 20033, "Ghostline Kyoto", MediaTypeGame, []string{"Action", "Adventure"}, []string{"PS5", "PC"}, "2026-02-14", 8.7, 1020),
newMedia(34, MediaProviderIGDB, 20034, "Snowfall County", MediaTypeGame, []string{"Simulation", "Adventure"}, []string{"Nintendo Switch", "PC"}, "2025-12-12", 7.9, 540),
newMedia(35, MediaProviderIGDB, 20035, "Titan Relay", MediaTypeGame, []string{"Shooter", "Action"}, []string{"PC", "PS5"}, "2026-04-22", 8.5, 780),
newMedia(36, MediaProviderIGDB, 20036, "Wild Circuit Stories", MediaTypeGame, []string{"Racing", "Indie"}, []string{"Nintendo Switch", "Xbox Series X|S"}, "2026-08-07", 8.0, 420),
}
mediaByID := make(map[int]MediaItem, len(allMedia))
for _, item := range allMedia {
mediaByID[item.ID] = item
}
sections := []sectionSeed{
{Kind: "trending", Title: "Trending", Subtitle: "Hot picks across screens and launchers", ItemIDs: []int{9, 25, 33, 21, 6, 17, 28, 23}},
{Kind: "popular", Title: "Popular", Subtitle: "High-signal releases people keep returning to", ItemIDs: []int{1, 2, 33, 5, 28, 8, 10, 31}},
{Kind: "top-rated", Title: "Top Rated", Subtitle: "Highest community ratings across all media", ItemIDs: []int{9, 17, 25, 5, 33, 16, 28, 21}},
{Kind: "upcoming", Title: "Upcoming", Subtitle: "Near-term drops on your radar", ItemIDs: []int{21, 25, 26, 14, 27, 23, 35, 36}},
{Kind: "now-playing", Title: "Now Playing", Subtitle: "Freshly added movies, episodes, and game launches", ItemIDs: []int{3, 6, 12, 29, 33, 30, 2, 17}},
{Kind: "airing-today", Title: "Airing Today", Subtitle: "Episodes and drops available now", ItemIDs: []int{6, 10, 17, 19, 24, 22, 4, 15}},
{Kind: "recently-released-games", Title: "Recently Released Games", Subtitle: "New launches worth checking this month", ItemIDs: []int{33, 31, 29, 30, 34, 32}},
{Kind: "most-anticipated-games", Title: "Most Anticipated Games", Subtitle: "Upcoming releases with strong momentum", ItemIDs: []int{25, 26, 27, 28, 35, 36, 31, 29}},
{Kind: "indie-highlights", Title: "Indie Highlights", Subtitle: "Smaller teams shipping sharper ideas", ItemIDs: []int{30, 36, 34, 32, 28, 26}},
}
dashboard := DashboardPayload{
WatchLater: mustPick(mediaByID, []int{25, 9, 14, 33, 21, 28}),
GameBacklog: mustPick(mediaByID, []int{25, 33, 28, 31}),
ActiveDownloads: []DownloadJob{
{ID: "dl-1024", Title: "Zero Meridian (2160p HDR)", Status: "downloading", ProgressPercent: 47, DownloadSpeedMbps: 23.8, EtaMinutes: 19, SourceType: "magnet"},
{ID: "dl-1025", Title: "Star Circuit Zero preload", Status: "queued", ProgressPercent: 0, DownloadSpeedMbps: 0, EtaMinutes: 34, SourceType: "http"},
{ID: "dl-1026", Title: "Arcadia Wire (1080p)", Status: "stalled", ProgressPercent: 74, DownloadSpeedMbps: 0.2, EtaMinutes: 120, SourceType: "torrent"},
},
Recommendations: []RecommendationItem{
{ID: 28, Reason: "Because your queue trends toward expansive fantasy worlds", Score: 93, Media: mustGet(mediaByID, 28)},
{ID: 24, Reason: "Because you finish serial thrillers quickly", Score: 89, Media: mustGet(mediaByID, 24)},
{ID: 33, Reason: "Because cinematic action games align with your recent picks", Score: 91, Media: mustGet(mediaByID, 33)},
{ID: 1, Reason: "Because your recent watches trend sci-fi", Score: 88, Media: mustGet(mediaByID, 1)},
},
Trending: mustPick(mediaByID, []int{9, 25, 33, 21, 16, 14}),
Upcoming: mustPick(mediaByID, []int{21, 25, 26, 14, 35}),
RecentlyWatched: mustPick(mediaByID, []int{2, 6, 17, 33, 29}),
}
continueWatching := []ContinueWatchingItem{
{Item: mustGet(mediaByID, 2), Progress: EpisodeProgress{ItemID: 2, SeasonNumber: 1, EpisodeNumber: 7, ProgressPercent: 63, LastWatchedAt: "2026-03-09T21:40:00Z"}},
{Item: mustGet(mediaByID, 17), Progress: EpisodeProgress{ItemID: 17, SeasonNumber: 2, EpisodeNumber: 2, ProgressPercent: 28, LastWatchedAt: "2026-03-08T23:16:00Z"}},
{Item: mustGet(mediaByID, 6), Progress: EpisodeProgress{ItemID: 6, SeasonNumber: 1, EpisodeNumber: 11, ProgressPercent: 82, LastWatchedAt: "2026-03-07T18:10:00Z"}},
}
return &Service{
repo: selected,
media: mediaByID,
allMedia: slices.Clone(allMedia),
sections: slices.Clone(sections),
dashboard: dashboard,
continueWatching: slices.Clone(continueWatching),
}
}
func (s *Service) SetGameLookup(lookup GameLookup) {
s.gameLookup = lookup
}
func (s *Service) Dashboard() DashboardPayload {
if s.repo != nil {
ctx := context.Background()
watchLater, err := s.repo.SectionItems(ctx, "top-rated", 5)
if err == nil {
gameBacklog, gameBacklogErr := s.repo.SectionItems(ctx, "most-anticipated-games", 4)
trending, trendingErr := s.repo.SectionItems(ctx, "trending", 6)
upcoming, upcomingErr := s.repo.SectionItems(ctx, "upcoming", 5)
recentlyWatched, recentlyErr := s.repo.SectionItems(ctx, "now-playing", 5)
if gameBacklogErr == nil && trendingErr == nil && upcomingErr == nil && recentlyErr == nil {
payload := DashboardPayload{
WatchLater: cloneMediaItems(watchLater),
GameBacklog: cloneMediaItems(gameBacklog),
ActiveDownloads: slices.Clone(s.dashboard.ActiveDownloads),
Recommendations: slices.Clone(s.dashboard.Recommendations),
Trending: cloneMediaItems(trending),
Upcoming: cloneMediaItems(upcoming),
RecentlyWatched: cloneMediaItems(recentlyWatched),
}
return payload
}
}
}
return DashboardPayload{
WatchLater: slices.Clone(s.dashboard.WatchLater),
GameBacklog: slices.Clone(s.dashboard.GameBacklog),
ActiveDownloads: slices.Clone(s.dashboard.ActiveDownloads),
Recommendations: slices.Clone(s.dashboard.Recommendations),
Trending: slices.Clone(s.dashboard.Trending),
Upcoming: slices.Clone(s.dashboard.Upcoming),
RecentlyWatched: slices.Clone(s.dashboard.RecentlyWatched),
}
}
func (s *Service) WatchLater(userID uuid.UUID) ([]MediaItem, error) {
if userID == uuid.Nil {
return nil, ErrInvalidInput
}
if s.repo != nil {
items, err := s.repo.ListWatchLater(context.Background(), userID)
if err == nil {
return cloneMediaItems(items), nil
}
}
return cloneMediaItems(s.dashboard.WatchLater), nil
}
func (s *Service) AddWatchLater(userID uuid.UUID, mediaID int) ([]MediaItem, error) {
if userID == uuid.Nil || mediaID < 1 {
return nil, ErrInvalidInput
}
if s.repo != nil {
if err := s.repo.AddWatchLater(context.Background(), userID, mediaID); err != nil {
if errors.Is(err, ErrMediaNotFound) {
return nil, ErrMediaNotFound
}
return nil, err
}
items, err := s.repo.ListWatchLater(context.Background(), userID)
if err != nil {
return nil, err
}
return cloneMediaItems(items), nil
}
updated, err := addToWatchLater(s.dashboard.WatchLater, s.media, mediaID)
if err != nil {
return nil, err
}
s.dashboard.WatchLater = updated
return cloneMediaItems(s.dashboard.WatchLater), nil
}
func (s *Service) RemoveWatchLater(userID uuid.UUID, mediaID int) ([]MediaItem, error) {
if userID == uuid.Nil || mediaID < 1 {
return nil, ErrInvalidInput
}
if s.repo != nil {
if err := s.repo.RemoveWatchLater(context.Background(), userID, mediaID); err != nil {
return nil, err
}
items, err := s.repo.ListWatchLater(context.Background(), userID)
if err != nil {
return nil, err
}
return cloneMediaItems(items), nil
}
s.dashboard.WatchLater = removeFromWatchLater(s.dashboard.WatchLater, mediaID)
return cloneMediaItems(s.dashboard.WatchLater), nil
}
func (s *Service) ContinueWatching(userID uuid.UUID) ([]ContinueWatchingItem, error) {
if userID == uuid.Nil {
return nil, ErrInvalidInput
}
if s.repo != nil {
items, err := s.repo.ListContinueWatching(context.Background(), userID, 12)
if err != nil {
return nil, err
}
return cloneContinueWatching(items), nil
}
return cloneContinueWatching(s.continueWatching), nil
}
func (s *Service) UpdateProgress(userID uuid.UUID, input ProgressUpdateInput) ([]ContinueWatchingItem, error) {
if userID == uuid.Nil || input.MediaID < 1 {
return nil, ErrInvalidInput
}
normalized := normalizeProgressInput(input)
if normalized.ProgressPercent < 0 || normalized.ProgressPercent > 100 {
return nil, ErrInvalidInput
}
if s.repo != nil {
if err := s.repo.UpsertProgress(context.Background(), userID, normalized); err != nil {
if errors.Is(err, ErrMediaNotFound) {
return nil, ErrMediaNotFound
}
return nil, err
}
items, err := s.repo.ListContinueWatching(context.Background(), userID, 12)
if err != nil {
return nil, err
}
return cloneContinueWatching(items), nil
}
updated, err := upsertContinueWatchingInMemory(s.continueWatching, s.media, normalized)
if err != nil {
return nil, err
}
s.continueWatching = updated
return cloneContinueWatching(s.continueWatching), nil
}
func (s *Service) Discover(params DiscoverParams) []DiscoverSection {
sanitized := sanitizeDiscoverParams(params)
if s.repo != nil {
sections, err := s.repo.Discover(context.Background(), sanitized)
if err == nil {
return withGenreInSectionTitle(sections, sanitized.Genre)
}
}
sections := make([]DiscoverSection, 0, len(s.sections))
for _, section := range s.sections {
candidates := mustPick(s.media, section.ItemIDs)
filtered := filterMedia(candidates, sanitized.Query, sanitized.Genre, sanitized.MediaType)
paged := page(filtered, sanitized.Page, sanitized.PageSize)
if len(paged) == 0 {
continue
}
title := section.Title
if sanitized.Genre != "" {
title = fmt.Sprintf("%s · %s", title, sanitized.Genre)
}
sections = append(sections, DiscoverSection{
Kind: section.Kind,
Title: title,
Subtitle: section.Subtitle,
Items: paged,
})
}
return sections
}
func (s *Service) Search(params SearchParams) []SearchResult {
query := strings.TrimSpace(strings.ToLower(params.Query))
if query == "" {
return []SearchResult{}
}
if s.repo != nil {
filtered, err := s.repo.SearchMedia(context.Background(), params)
if err == nil {
return buildSearchResults(filtered, query)
}
}
filtered := filterMedia(s.allMedia, query, strings.TrimSpace(params.Genre), strings.TrimSpace(params.MediaType))
if s.gameLookup != nil && shouldIncludeGameLookup(params.MediaType) {
remote, err := s.gameLookup.SearchGames(context.Background(), params.Query, 12)
if err == nil {
filtered = mergeMediaItems(
filtered,
filterMedia(remote, query, strings.TrimSpace(params.Genre), string(MediaTypeGame)),
)
}
}
return buildSearchResults(filtered, query)
}
func buildSearchResults(items []MediaItem, query string) []SearchResult {
results := make([]SearchResult, 0, len(items))
for _, item := range items {
subtitle := fmt.Sprintf("%s · %s", strings.Join(item.Genres, " • "), releaseYear(item.ReleaseDate))
if item.Type == MediaTypeGame && len(item.Platforms) > 0 {
subtitle = fmt.Sprintf("%s · %s · %s", strings.Join(item.Genres, " • "), strings.Join(item.Platforms, " • "), releaseYear(item.ReleaseDate))
}
results = append(results, SearchResult{
ID: item.ID,
MediaType: string(item.Type),
Title: item.Title,
Subtitle: subtitle,
Genres: slices.Clone(item.Genres),
Score: score(item.Title, query, item.Rating),
})
}
slices.SortFunc(results, func(left SearchResult, right SearchResult) int {
return right.Score - left.Score
})
if len(results) > 12 {
return slices.Clone(results[:12])
}
return results
}
func releaseYear(releaseDate string) string {
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
func shouldIncludeGameLookup(mediaType string) bool {
cleanType := strings.ToLower(strings.TrimSpace(mediaType))
return cleanType == "" || cleanType == "all" || cleanType == string(MediaTypeGame)
}
func mergeMediaItems(existing []MediaItem, incoming []MediaItem) []MediaItem {
if len(incoming) == 0 {
return existing
}
seen := make(map[string]struct{}, len(existing)+len(incoming))
merged := make([]MediaItem, 0, len(existing)+len(incoming))
for _, item := range existing {
key := fmt.Sprintf("%s:%d", item.Provider, item.ProviderID)
seen[key] = struct{}{}
merged = append(merged, item)
}
for _, item := range incoming {
key := fmt.Sprintf("%s:%d", item.Provider, item.ProviderID)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
merged = append(merged, item)
}
return merged
}
func withGenreInSectionTitle(sections []DiscoverSection, genre string) []DiscoverSection {
cleanGenre := strings.TrimSpace(genre)
if cleanGenre == "" {
return sections
}
updated := make([]DiscoverSection, 0, len(sections))
for _, section := range sections {
updated = append(updated, DiscoverSection{
Kind: section.Kind,
Title: fmt.Sprintf("%s · %s", section.Title, cleanGenre),
Subtitle: section.Subtitle,
Items: cloneMediaItems(section.Items),
})
}
return updated
}
func cloneMediaItems(items []MediaItem) []MediaItem {
if len(items) == 0 {
return []MediaItem{}
}
cloned := make([]MediaItem, 0, len(items))
for _, item := range items {
cloned = append(cloned, MediaItem{
ID: item.ID,
Provider: item.Provider,
ProviderID: item.ProviderID,
Title: item.Title,
Overview: item.Overview,
Type: item.Type,
ReleaseDate: item.ReleaseDate,
Genres: cloneStringSlice(item.Genres),
Platforms: cloneStringSlice(item.Platforms),
Rating: item.Rating,
RuntimeMinutes: item.RuntimeMinutes,
ArtworkKey: item.ArtworkKey,
})
}
return cloned
}
func cloneContinueWatching(items []ContinueWatchingItem) []ContinueWatchingItem {
if len(items) == 0 {
return []ContinueWatchingItem{}
}
cloned := make([]ContinueWatchingItem, 0, len(items))
for _, item := range items {
cloned = append(cloned, ContinueWatchingItem{
Item: MediaItem{
ID: item.Item.ID,
Provider: item.Item.Provider,
ProviderID: item.Item.ProviderID,
Title: item.Item.Title,
Overview: item.Item.Overview,
Type: item.Item.Type,
ReleaseDate: item.Item.ReleaseDate,
Genres: cloneStringSlice(item.Item.Genres),
Platforms: cloneStringSlice(item.Item.Platforms),
Rating: item.Item.Rating,
RuntimeMinutes: item.Item.RuntimeMinutes,
ArtworkKey: item.Item.ArtworkKey,
},
Progress: EpisodeProgress{
ItemID: item.Progress.ItemID,
SeasonNumber: item.Progress.SeasonNumber,
EpisodeNumber: item.Progress.EpisodeNumber,
ProgressPercent: item.Progress.ProgressPercent,
LastWatchedAt: item.Progress.LastWatchedAt,
},
})
}
return cloned
}
func normalizeProgressInput(input ProgressUpdateInput) ProgressUpdateInput {
season := input.SeasonNumber
if season < 1 {
season = 1
}
episode := input.EpisodeNumber
if episode < 1 {
episode = 1
}
return ProgressUpdateInput{
MediaID: input.MediaID,
SeasonNumber: season,
EpisodeNumber: episode,
ProgressPercent: input.ProgressPercent,
}
}
func upsertContinueWatchingInMemory(
existing []ContinueWatchingItem,
catalogMap map[int]MediaItem,
input ProgressUpdateInput,
) ([]ContinueWatchingItem, error) {
next := make([]ContinueWatchingItem, 0, len(existing)+1)
for _, entry := range existing {
if entry.Item.ID == input.MediaID &&
entry.Progress.SeasonNumber == input.SeasonNumber &&
entry.Progress.EpisodeNumber == input.EpisodeNumber {
continue
}
next = append(next, entry)
}
if input.ProgressPercent <= 0 || input.ProgressPercent >= 100 {
return next, nil
}
media, ok := catalogMap[input.MediaID]
if !ok {
return nil, ErrMediaNotFound
}
newEntry := ContinueWatchingItem{
Item: media,
Progress: EpisodeProgress{
ItemID: input.MediaID,
SeasonNumber: input.SeasonNumber,
EpisodeNumber: input.EpisodeNumber,
ProgressPercent: input.ProgressPercent,
LastWatchedAt: time.Now().UTC().Format(time.RFC3339),
},
}
next = append([]ContinueWatchingItem{newEntry}, next...)
if len(next) > 12 {
return next[:12], nil
}
return next, nil
}
func addToWatchLater(existing []MediaItem, catalogMap map[int]MediaItem, mediaID int) ([]MediaItem, error) {
for _, item := range existing {
if item.ID == mediaID {
return existing, nil
}
}
media, ok := catalogMap[mediaID]
if !ok {
return nil, ErrMediaNotFound
}
next := make([]MediaItem, 0, len(existing)+1)
next = append(next, media)
next = append(next, cloneMediaItems(existing)...)
return next, nil
}
func removeFromWatchLater(existing []MediaItem, mediaID int) []MediaItem {
next := make([]MediaItem, 0, len(existing))
for _, item := range existing {
if item.ID == mediaID {
continue
}
next = append(next, item)
}
return next
}
func sanitizeDiscoverParams(params DiscoverParams) DiscoverParams {
page := params.Page
if page < 1 {
page = 1
}
pageSize := params.PageSize
if pageSize < 1 {
pageSize = 6
}
return DiscoverParams{
Page: page,
PageSize: pageSize,
Query: strings.TrimSpace(params.Query),
Genre: strings.TrimSpace(params.Genre),
MediaType: strings.TrimSpace(params.MediaType),
}
}
func filterMedia(items []MediaItem, query string, genre string, mediaType string) []MediaItem {
queryLower := strings.ToLower(strings.TrimSpace(query))
genreLower := strings.ToLower(strings.TrimSpace(genre))
mediaTypeLower := strings.ToLower(strings.TrimSpace(mediaType))
result := make([]MediaItem, 0, len(items))
for _, item := range items {
if mediaTypeLower != "" && mediaTypeLower != "all" && mediaTypeLower != string(item.Type) {
continue
}
if genreLower != "" && !hasGenre(item, genreLower) {
continue
}
if queryLower != "" && !matchesQuery(item, queryLower) {
continue
}
result = append(result, item)
}
return result
}
func hasGenre(item MediaItem, genre string) bool {
for _, existing := range item.Genres {
if strings.ToLower(existing) == genre {
return true
}
}
return false
}
func matchesQuery(item MediaItem, query string) bool {
if strings.Contains(strings.ToLower(item.Title), query) {
return true
}
if strings.Contains(strings.ToLower(item.Overview), query) {
return true
}
for _, genre := range item.Genres {
if strings.Contains(strings.ToLower(genre), query) {
return true
}
}
return false
}
func page(items []MediaItem, pageNumber int, pageSize int) []MediaItem {
start := (pageNumber - 1) * pageSize
if start >= len(items) {
return []MediaItem{}
}
end := start + pageSize
if end > len(items) {
end = len(items)
}
return slices.Clone(items[start:end])
}
func cloneStringSlice(values []string) []string {
if len(values) == 0 {
return []string{}
}
return slices.Clone(values)
}
func score(title string, query string, rating float64) int {
cleanTitle := strings.ToLower(strings.TrimSpace(title))
if cleanTitle == query {
return min(100, int(70+rating*3))
}
if strings.HasPrefix(cleanTitle, query) {
return min(98, int(60+rating*3))
}
return min(95, int(45+rating*4))
}
func min(a int, b int) int {
if a < b {
return a
}
return b
}
func mustPick(media map[int]MediaItem, ids []int) []MediaItem {
items := make([]MediaItem, 0, len(ids))
for _, id := range ids {
items = append(items, mustGet(media, id))
}
return items
}
func mustGet(media map[int]MediaItem, id int) MediaItem {
item, ok := media[id]
if !ok {
panic(fmt.Sprintf("missing media item %d", id))
}
return item
}
func newMedia(
id int,
provider MediaProvider,
providerID int,
title string,
mediaType MediaType,
genres []string,
platforms []string,
releaseDate string,
rating float64,
runtimeMinutes int,
) MediaItem {
overview := fmt.Sprintf("%s is a premium catalog title in your Seen library.", title)
if mediaType == MediaTypeGame {
overview = fmt.Sprintf("%s is a high-signal game release tracked through your IGDB-powered backlog.", title)
}
return MediaItem{
ID: id,
Provider: provider,
ProviderID: providerID,
Title: title,
Overview: overview,
Type: mediaType,
ReleaseDate: releaseDate,
Genres: cloneStringSlice(genres),
Platforms: cloneStringSlice(platforms),
Rating: rating,
RuntimeMinutes: runtimeMinutes,
ArtworkKey: fmt.Sprintf("%s-%d", title, id),
}
}
@@ -0,0 +1,116 @@
package catalog
import (
"testing"
"github.com/google/uuid"
)
func TestDiscoverFilters(t *testing.T) {
svc := NewService()
sections := svc.Discover(DiscoverParams{
Page: 1,
PageSize: 3,
Genre: "Sci-Fi",
MediaType: "movie",
})
if len(sections) == 0 {
t.Fatalf("expected non-empty discover sections")
}
for _, section := range sections {
for _, item := range section.Items {
if item.Type != MediaTypeMovie {
t.Fatalf("expected movie item, got %s", item.Type)
}
}
}
}
func TestDiscoverSupportsGames(t *testing.T) {
svc := NewService()
sections := svc.Discover(DiscoverParams{
Page: 1,
PageSize: 4,
MediaType: "game",
})
if len(sections) == 0 {
t.Fatalf("expected game sections")
}
for _, section := range sections {
for _, item := range section.Items {
if item.Type != MediaTypeGame {
t.Fatalf("expected game item, got %s", item.Type)
}
}
}
}
func TestSearch(t *testing.T) {
svc := NewService()
results := svc.Search(SearchParams{Query: "zero", MediaType: "all"})
if len(results) == 0 {
t.Fatalf("expected search results")
}
if results[0].Title != "Zero Meridian" {
t.Fatalf("expected top result Zero Meridian, got %s", results[0].Title)
}
}
func TestSearchGames(t *testing.T) {
svc := NewService()
results := svc.Search(SearchParams{Query: "ghostline", MediaType: "game"})
if len(results) == 0 {
t.Fatalf("expected game search results")
}
if results[0].MediaType != string(MediaTypeGame) {
t.Fatalf("expected game result, got %s", results[0].MediaType)
}
}
func TestUpdateProgressInMemory(t *testing.T) {
svc := NewService()
userID := uuid.New()
updated, err := svc.UpdateProgress(userID, ProgressUpdateInput{
MediaID: 2,
SeasonNumber: 1,
EpisodeNumber: 7,
ProgressPercent: 100,
})
if err != nil {
t.Fatalf("expected update to succeed, got error: %v", err)
}
for _, entry := range updated {
if entry.Item.ID == 2 &&
entry.Progress.SeasonNumber == 1 &&
entry.Progress.EpisodeNumber == 7 {
t.Fatalf("expected completed progress to be excluded from continue watching")
}
}
}
func TestDashboardIncludesGameBacklog(t *testing.T) {
svc := NewService()
payload := svc.Dashboard()
if len(payload.GameBacklog) == 0 {
t.Fatalf("expected dashboard game backlog")
}
for _, item := range payload.GameBacklog {
if item.Type != MediaTypeGame {
t.Fatalf("expected game backlog item, got %s", item.Type)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package catalog
type MediaType string
const (
MediaTypeMovie MediaType = "movie"
MediaTypeShow MediaType = "show"
MediaTypeGame MediaType = "game"
)
type MediaProvider string
const (
MediaProviderTMDB MediaProvider = "tmdb"
MediaProviderIGDB MediaProvider = "igdb"
)
type MediaItem struct {
ID int `json:"id"`
Provider MediaProvider `json:"provider"`
ProviderID int `json:"providerId"`
Title string `json:"title"`
Overview string `json:"overview"`
Type MediaType `json:"type"`
ReleaseDate string `json:"releaseDate"`
Genres []string `json:"genres"`
Platforms []string `json:"platforms"`
Rating float64 `json:"rating"`
RuntimeMinutes int `json:"runtimeMinutes"`
ArtworkKey string `json:"artworkKey"`
}
type EpisodeProgress struct {
ItemID int `json:"itemId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
ProgressPercent int `json:"progressPercent"`
LastWatchedAt string `json:"lastWatchedAt"`
}
type ContinueWatchingItem struct {
Item MediaItem `json:"item"`
Progress EpisodeProgress `json:"progress"`
}
type DownloadJob struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
ProgressPercent int `json:"progressPercent"`
DownloadSpeedMbps float64 `json:"downloadSpeedMbps"`
EtaMinutes int `json:"etaMinutes"`
SourceType string `json:"sourceType"`
}
type RecommendationItem struct {
ID int `json:"id"`
Reason string `json:"reason"`
Score int `json:"score"`
Media MediaItem `json:"media"`
}
type DashboardPayload struct {
WatchLater []MediaItem `json:"watchLater"`
GameBacklog []MediaItem `json:"gameBacklog"`
ActiveDownloads []DownloadJob `json:"activeDownloads"`
Recommendations []RecommendationItem `json:"recommendations"`
Trending []MediaItem `json:"trending"`
Upcoming []MediaItem `json:"upcoming"`
RecentlyWatched []MediaItem `json:"recentlyWatched"`
}
type DiscoverSection struct {
Kind string `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Items []MediaItem `json:"items"`
}
type SearchResult struct {
ID int `json:"id"`
MediaType string `json:"mediaType"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
Genres []string `json:"genres"`
Score int `json:"score"`
}
type DiscoverParams struct {
Page int
PageSize int
Query string
Genre string
MediaType string
}
type SearchParams struct {
Query string
Genre string
MediaType string
}
type ProgressUpdateInput struct {
MediaID int
SeasonNumber int
EpisodeNumber int
ProgressPercent int
}
@@ -0,0 +1,339 @@
package download
import (
"context"
"errors"
"slices"
"strings"
"time"
"github.com/google/uuid"
)
type Status string
const (
StatusQueued Status = "queued"
StatusPreparing Status = "preparing"
StatusDownloading Status = "downloading"
StatusStalled Status = "stalled"
StatusRetrying Status = "retrying"
StatusCompleted Status = "completed"
StatusFailed Status = "failed"
StatusCancelled Status = "cancelled"
)
var (
ErrInvalidInput = errors.New("invalid input")
ErrNotFound = errors.New("download job not found")
)
type Job struct {
ID string `json:"id"`
UserID string `json:"userId,omitempty"`
SourceType string `json:"sourceType"`
Source string `json:"source,omitempty"`
Title string `json:"title"`
Status string `json:"status"`
QueuePosition int `json:"queuePosition,omitempty"`
ProgressPercent int `json:"progressPercent"`
BytesTotal int64 `json:"bytesTotal"`
BytesDownloaded int64 `json:"bytesDownloaded"`
DownloadSpeedMbps float64 `json:"downloadSpeedMbps"`
EtaSeconds int `json:"etaSeconds,omitempty"`
ErrorMessage string `json:"errorMessage,omitempty"`
RetryCount int `json:"retryCount"`
CreatedAt time.Time `json:"createdAt,omitempty"`
UpdatedAt time.Time `json:"updatedAt,omitempty"`
StartedAt time.Time `json:"startedAt,omitempty"`
CompletedAt time.Time `json:"completedAt,omitempty"`
CancelledAt time.Time `json:"cancelledAt,omitempty"`
}
type Event struct {
ID int64 `json:"id"`
JobID string `json:"jobId"`
Status string `json:"status"`
Message string `json:"message"`
ProgressPercent int `json:"progressPercent"`
Payload string `json:"payload"`
CreatedAt time.Time `json:"createdAt"`
}
type CreateInput struct {
SourceType string
Source string
Title string
}
type ListParams struct {
Status string
Limit int
Offset int
}
type EventParams struct {
Limit int
After string
}
type UpdateInput struct {
Status string
ProgressPercent int
BytesTotal int64
BytesDownloaded int64
DownloadSpeedMbps float64
EtaSeconds int
ErrorMessage string
RetryCount int
}
type Repository interface {
CreateJob(ctx context.Context, userID uuid.UUID, input CreateInput) (Job, error)
ListJobs(ctx context.Context, userID uuid.UUID, params ListParams) ([]Job, error)
GetJobByID(ctx context.Context, userID uuid.UUID, jobID string) (Job, error)
CancelJob(ctx context.Context, userID uuid.UUID, jobID string) (Job, error)
ListEvents(ctx context.Context, userID uuid.UUID, jobID string, params EventParams) ([]Event, error)
AppendEvent(ctx context.Context, jobID string, event Event) error
UpdateJob(ctx context.Context, userID uuid.UUID, jobID string, input UpdateInput) (Job, error)
}
type Service struct {
repo Repository
}
func NewService(repo Repository) *Service {
return &Service{repo: repo}
}
func (s *Service) Create(ctx context.Context, userID uuid.UUID, input CreateInput) (Job, error) {
if userID == uuid.Nil {
return Job{}, ErrInvalidInput
}
sourceType := strings.TrimSpace(strings.ToLower(input.SourceType))
source := strings.TrimSpace(input.Source)
title := strings.TrimSpace(input.Title)
if sourceType == "" || source == "" {
return Job{}, ErrInvalidInput
}
if !isAllowedSourceType(sourceType) {
return Job{}, ErrInvalidInput
}
return s.repo.CreateJob(ctx, userID, CreateInput{
SourceType: sourceType,
Source: source,
Title: title,
})
}
func (s *Service) List(ctx context.Context, userID uuid.UUID, params ListParams) ([]Job, error) {
if userID == uuid.Nil {
return nil, ErrInvalidInput
}
limit := params.Limit
if limit < 1 || limit > 100 {
limit = 20
}
offset := params.Offset
if offset < 0 {
offset = 0
}
status := strings.TrimSpace(strings.ToLower(params.Status))
if status != "" && !isAllowedStatus(status) {
return nil, ErrInvalidInput
}
return s.repo.ListJobs(ctx, userID, ListParams{
Status: status,
Limit: limit,
Offset: offset,
})
}
func (s *Service) Cancel(ctx context.Context, userID uuid.UUID, jobID string) (Job, error) {
if userID == uuid.Nil || strings.TrimSpace(jobID) == "" {
return Job{}, ErrInvalidInput
}
job, err := s.repo.GetJobByID(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
if isTerminalStatus(job.Status) {
return job, nil
}
updated, err := s.repo.CancelJob(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
_ = s.repo.AppendEvent(ctx, updated.ID, Event{
Status: StatusCancelled.String(),
Message: "job cancelled by user",
ProgressPercent: updated.ProgressPercent,
Payload: "{}",
})
return updated, nil
}
func (s *Service) Events(ctx context.Context, userID uuid.UUID, jobID string, params EventParams) ([]Event, error) {
if userID == uuid.Nil || strings.TrimSpace(jobID) == "" {
return nil, ErrInvalidInput
}
limit := params.Limit
if limit < 1 || limit > 200 {
limit = 100
}
_, err := s.repo.GetJobByID(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return nil, ErrNotFound
}
return nil, err
}
return s.repo.ListEvents(ctx, userID, jobID, EventParams{
Limit: limit,
After: strings.TrimSpace(params.After),
})
}
func (s *Service) Transition(ctx context.Context, userID uuid.UUID, jobID string, input UpdateInput) (Job, error) {
if userID == uuid.Nil || strings.TrimSpace(jobID) == "" {
return Job{}, ErrInvalidInput
}
nextStatus := strings.TrimSpace(strings.ToLower(input.Status))
if !isAllowedStatus(nextStatus) {
return Job{}, ErrInvalidInput
}
current, err := s.repo.GetJobByID(ctx, userID, jobID)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
if !canTransition(current.Status, nextStatus) {
return Job{}, ErrInvalidInput
}
if input.ProgressPercent < current.ProgressPercent && !isTerminalStatus(nextStatus) {
return Job{}, ErrInvalidInput
}
if nextStatus == StatusCompleted.String() {
input.ProgressPercent = 100
}
updated, err := s.repo.UpdateJob(ctx, userID, jobID, input)
if err != nil {
if errors.Is(err, ErrNotFound) {
return Job{}, ErrNotFound
}
return Job{}, err
}
_ = s.repo.AppendEvent(ctx, updated.ID, Event{
Status: updated.Status,
Message: eventMessageForStatus(updated.Status),
ProgressPercent: updated.ProgressPercent,
Payload: "{}",
})
return updated, nil
}
func isAllowedSourceType(value string) bool {
return slices.Contains([]string{"magnet", "torrent", "direct", "http"}, value)
}
func isAllowedStatus(value string) bool {
return slices.Contains([]string{
StatusQueued.String(),
StatusPreparing.String(),
StatusDownloading.String(),
StatusStalled.String(),
StatusRetrying.String(),
StatusCompleted.String(),
StatusFailed.String(),
StatusCancelled.String(),
}, value)
}
func isTerminalStatus(value string) bool {
return slices.Contains([]string{
StatusCompleted.String(),
StatusFailed.String(),
StatusCancelled.String(),
}, strings.TrimSpace(strings.ToLower(value)))
}
func canTransition(from string, to string) bool {
current := strings.TrimSpace(strings.ToLower(from))
next := strings.TrimSpace(strings.ToLower(to))
if current == next {
return true
}
switch current {
case StatusQueued.String():
return next == StatusPreparing.String() || next == StatusCancelled.String()
case StatusPreparing.String():
return next == StatusDownloading.String() || next == StatusCancelled.String()
case StatusDownloading.String():
return next == StatusStalled.String() || next == StatusCompleted.String() || next == StatusFailed.String() || next == StatusCancelled.String()
case StatusStalled.String():
return next == StatusRetrying.String() || next == StatusCancelled.String()
case StatusRetrying.String():
return next == StatusPreparing.String() || next == StatusCancelled.String()
default:
return false
}
}
func eventMessageForStatus(status string) string {
switch strings.TrimSpace(strings.ToLower(status)) {
case StatusPreparing.String():
return "job preparing"
case StatusDownloading.String():
return "download started"
case StatusStalled.String():
return "download stalled"
case StatusRetrying.String():
return "retrying stalled download"
case StatusCompleted.String():
return "download completed"
case StatusFailed.String():
return "download failed"
case StatusCancelled.String():
return "download cancelled"
default:
return "download updated"
}
}
func (s Status) String() string {
return string(s)
}
+33
View File
@@ -0,0 +1,33 @@
package workers
import (
"context"
"go.uber.org/zap"
)
type Worker interface {
Name() string
Start(ctx context.Context) error
}
type Manager struct {
workers []Worker
log *zap.Logger
}
func NewManager(log *zap.Logger, workers ...Worker) *Manager {
return &Manager{workers: workers, log: log}
}
func (m *Manager) Start(ctx context.Context) {
for _, worker := range m.workers {
worker := worker
go func() {
m.log.Info("starting worker", zap.String("worker", worker.Name()))
if err := worker.Start(ctx); err != nil {
m.log.Error("worker stopped", zap.String("worker", worker.Name()), zap.Error(err))
}
}()
}
}