mirror of
https://github.com/Dvorinka/SEEN.git
synced 2026-06-04 20:43:03 +00:00
small fix, don't worry about it
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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,
|
||||
§ionTitle,
|
||||
§ionSubtitle,
|
||||
&displayOrder,
|
||||
§ionPosition,
|
||||
&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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ©, 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 ©, 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 ©, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user