small fix, don't worry about it

This commit is contained in:
Tomas Dvorak
2026-04-10 12:06:24 +02:00
commit 5c500a72b0
243 changed files with 44176 additions and 0 deletions
@@ -0,0 +1,136 @@
package handlers
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/services/auth"
"github.com/tdvorak/seen/backend/pkg/httpx"
)
type AuthHandler struct {
service *auth.Service
}
func NewAuthHandler(service *auth.Service) *AuthHandler {
return &AuthHandler{service: service}
}
type registerRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
}
type loginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
type refreshRequest struct {
RefreshToken string `json:"refreshToken"`
}
func (h *AuthHandler) Me(c *gin.Context) {
accessToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
httpx.JSONError(c, http.StatusUnauthorized, "missing bearer token")
return
}
user, err := h.service.UserFromAccessToken(c.Request.Context(), accessToken)
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidToken):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to resolve user")
}
return
}
httpx.JSON(c, http.StatusOK, user)
}
func (h *AuthHandler) Register(c *gin.Context) {
var request registerRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.service.Register(c.Request.Context(), auth.RegisterInput{
Email: request.Email,
Password: request.Password,
DisplayName: request.DisplayName,
})
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrEmailTaken):
httpx.JSONError(c, http.StatusConflict, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "register failed")
}
return
}
httpx.JSON(c, http.StatusCreated, result)
}
func (h *AuthHandler) Login(c *gin.Context) {
var request loginRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.service.Login(c.Request.Context(), auth.LoginInput{
Email: request.Email,
Password: request.Password,
UserAgent: c.GetHeader("User-Agent"),
IP: c.ClientIP(),
})
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrInvalidCredentials):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "login failed")
}
return
}
httpx.JSON(c, http.StatusOK, result)
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var request refreshRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
result, err := h.service.Refresh(c.Request.Context(), auth.RefreshInput{
RefreshToken: request.RefreshToken,
UserAgent: c.GetHeader("User-Agent"),
IP: c.ClientIP(),
})
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, auth.ErrInvalidSession):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "refresh failed")
}
return
}
httpx.JSON(c, http.StatusOK, result)
}
+22
View File
@@ -0,0 +1,22 @@
package handlers
import "strings"
func bearerToken(header string) (string, bool) {
trimmed := strings.TrimSpace(header)
if trimmed == "" {
return "", false
}
parts := strings.SplitN(trimmed, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
return "", false
}
token := strings.TrimSpace(parts[1])
if token == "" {
return "", false
}
return token, true
}
@@ -0,0 +1,227 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/services/auth"
"github.com/tdvorak/seen/backend/internal/services/catalog"
"github.com/tdvorak/seen/backend/pkg/httpx"
)
type CatalogHandler struct {
service *catalog.Service
authService *auth.Service
}
func NewCatalogHandler(service *catalog.Service, authService *auth.Service) *CatalogHandler {
return &CatalogHandler{service: service, authService: authService}
}
type watchLaterAddRequest struct {
MediaID int `json:"mediaId"`
}
type progressUpdateRequest struct {
MediaID int `json:"mediaId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
ProgressPercent int `json:"progressPercent"`
}
func (h *CatalogHandler) Dashboard(c *gin.Context) {
httpx.JSON(c, http.StatusOK, h.service.Dashboard())
}
func (h *CatalogHandler) ContinueWatching(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
items, err := h.service.ContinueWatching(user.ID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to load continue watching")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) Discover(c *gin.Context) {
page := parseInt(c.Query("page"), 1)
pageSize := parseInt(c.Query("pageSize"), 6)
httpx.JSON(c, http.StatusOK, h.service.Discover(catalog.DiscoverParams{
Page: page,
PageSize: pageSize,
Query: c.Query("query"),
Genre: c.Query("genre"),
MediaType: c.Query("mediaType"),
}))
}
func (h *CatalogHandler) Games(c *gin.Context) {
page := parseInt(c.Query("page"), 1)
pageSize := parseInt(c.Query("pageSize"), 6)
httpx.JSON(c, http.StatusOK, h.service.Discover(catalog.DiscoverParams{
Page: page,
PageSize: pageSize,
Query: c.Query("query"),
Genre: c.Query("genre"),
MediaType: string(catalog.MediaTypeGame),
}))
}
func (h *CatalogHandler) Search(c *gin.Context) {
httpx.JSON(c, http.StatusOK, h.service.Search(catalog.SearchParams{
Query: c.Query("query"),
Genre: c.Query("genre"),
MediaType: c.Query("mediaType"),
}))
}
func (h *CatalogHandler) WatchLater(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
items, err := h.service.WatchLater(user.ID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to load watch later")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) AddWatchLater(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
var request watchLaterAddRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
items, err := h.service.AddWatchLater(user.ID, request.MediaID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, catalog.ErrMediaNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to add watch later item")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) RemoveWatchLater(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
mediaID := parseInt(c.Param("mediaId"), 0)
items, err := h.service.RemoveWatchLater(user.ID, mediaID)
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to remove watch later item")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) UpdateProgress(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
var request progressUpdateRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
items, err := h.service.UpdateProgress(user.ID, catalog.ProgressUpdateInput{
MediaID: request.MediaID,
SeasonNumber: request.SeasonNumber,
EpisodeNumber: request.EpisodeNumber,
ProgressPercent: request.ProgressPercent,
})
if err != nil {
switch {
case errors.Is(err, catalog.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, catalog.ErrMediaNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to update progress")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *CatalogHandler) resolveUser(c *gin.Context) (*domain.User, bool) {
accessToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
httpx.JSONError(c, http.StatusUnauthorized, "missing bearer token")
return nil, false
}
user, err := h.authService.UserFromAccessToken(c.Request.Context(), accessToken)
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidToken):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to resolve user")
}
return nil, false
}
return user, true
}
func parseInt(raw string, fallback int) int {
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return parsed
}
@@ -0,0 +1,168 @@
package handlers
import (
"errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/domain"
"github.com/tdvorak/seen/backend/internal/services/auth"
"github.com/tdvorak/seen/backend/internal/services/download"
"github.com/tdvorak/seen/backend/pkg/httpx"
)
type DownloadHandler struct {
service *download.Service
authService *auth.Service
}
func NewDownloadHandler(service *download.Service, authService *auth.Service) *DownloadHandler {
return &DownloadHandler{
service: service,
authService: authService,
}
}
type createDownloadRequest struct {
SourceType string `json:"sourceType"`
Source string `json:"source"`
Title string `json:"title"`
}
func (h *DownloadHandler) Create(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
var request createDownloadRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.JSONError(c, http.StatusBadRequest, "invalid request body")
return
}
job, err := h.service.Create(c.Request.Context(), user.ID, download.CreateInput{
SourceType: request.SourceType,
Source: request.Source,
Title: request.Title,
})
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to create download job")
}
return
}
httpx.JSON(c, http.StatusCreated, job)
}
func (h *DownloadHandler) List(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
items, err := h.service.List(c.Request.Context(), user.ID, download.ListParams{
Status: c.Query("status"),
Limit: parseIntSafe(c.Query("limit"), 20),
Offset: parseIntSafe(c.Query("offset"), 0),
})
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to list download jobs")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *DownloadHandler) Cancel(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
jobID := c.Param("id")
job, err := h.service.Cancel(c.Request.Context(), user.ID, jobID)
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, download.ErrNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to cancel download job")
}
return
}
httpx.JSON(c, http.StatusOK, job)
}
func (h *DownloadHandler) Events(c *gin.Context) {
user, ok := h.resolveUser(c)
if !ok {
return
}
jobID := c.Param("id")
items, err := h.service.Events(c.Request.Context(), user.ID, jobID, download.EventParams{
After: c.Query("after"),
Limit: parseIntSafe(c.Query("limit"), 100),
})
if err != nil {
switch {
case errors.Is(err, download.ErrInvalidInput):
httpx.JSONError(c, http.StatusBadRequest, err.Error())
case errors.Is(err, download.ErrNotFound):
httpx.JSONError(c, http.StatusNotFound, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to list download events")
}
return
}
httpx.JSON(c, http.StatusOK, items)
}
func (h *DownloadHandler) resolveUser(c *gin.Context) (*domain.User, bool) {
accessToken, ok := bearerToken(c.GetHeader("Authorization"))
if !ok {
httpx.JSONError(c, http.StatusUnauthorized, "missing bearer token")
return nil, false
}
user, err := h.authService.UserFromAccessToken(c.Request.Context(), accessToken)
if err != nil {
switch {
case errors.Is(err, auth.ErrInvalidToken):
httpx.JSONError(c, http.StatusUnauthorized, err.Error())
default:
httpx.JSONError(c, http.StatusInternalServerError, "failed to resolve user")
}
return nil, false
}
return user, true
}
func parseIntSafe(raw string, fallback int) int {
if raw == "" {
return fallback
}
parsed, err := strconv.Atoi(raw)
if err != nil {
return fallback
}
return parsed
}
@@ -0,0 +1,49 @@
package handlers
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
)
type HealthHandler struct {
db *pgxpool.Pool
cache *redis.Client
}
func NewHealthHandler(db *pgxpool.Pool, cache *redis.Client) *HealthHandler {
return &HealthHandler{db: db, cache: cache}
}
func (h *HealthHandler) Live(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok", "timestamp": time.Now().UTC()})
}
func (h *HealthHandler) Ready(c *gin.Context) {
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
defer cancel()
health := gin.H{"status": "ready"}
if err := h.db.Ping(ctx); err != nil {
health["status"] = "degraded"
health["postgres"] = err.Error()
c.JSON(http.StatusServiceUnavailable, health)
return
}
if err := h.cache.Ping(ctx).Err(); err != nil {
health["status"] = "degraded"
health["dragonfly"] = err.Error()
c.JSON(http.StatusServiceUnavailable, health)
return
}
health["postgres"] = "ok"
health["dragonfly"] = "ok"
c.JSON(http.StatusOK, health)
}
@@ -0,0 +1,22 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
)
type PlaceholderHandler struct{}
func NewPlaceholderHandler() *PlaceholderHandler {
return &PlaceholderHandler{}
}
func (h *PlaceholderHandler) NotImplemented(feature string) gin.HandlerFunc {
return func(c *gin.Context) {
c.JSON(http.StatusNotImplemented, gin.H{
"error": "not implemented",
"feature": feature,
})
}
}
+50
View File
@@ -0,0 +1,50 @@
package v1
import (
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/api/handlers"
)
func Register(
router *gin.RouterGroup,
health *handlers.HealthHandler,
auth *handlers.AuthHandler,
catalog *handlers.CatalogHandler,
download *handlers.DownloadHandler,
placeholder *handlers.PlaceholderHandler,
) {
router.GET("/health/live", health.Live)
router.GET("/health/ready", health.Ready)
authGroup := router.Group("/auth")
authGroup.POST("/register", auth.Register)
authGroup.POST("/login", auth.Login)
authGroup.POST("/refresh", auth.Refresh)
authGroup.GET("/me", auth.Me)
router.GET("/dashboard", catalog.Dashboard)
router.GET("/progress/continue-watching", catalog.ContinueWatching)
router.GET("/discover", catalog.Discover)
router.GET("/games", catalog.Games)
router.GET("/search", catalog.Search)
router.GET("/watch-later", catalog.WatchLater)
router.POST("/watch-later", catalog.AddWatchLater)
router.DELETE("/watch-later/:mediaId", catalog.RemoveWatchLater)
router.POST("/progress", catalog.UpdateProgress)
router.GET("/movies", placeholder.NotImplemented("movies"))
router.GET("/shows", placeholder.NotImplemented("shows"))
router.GET("/watched", placeholder.NotImplemented("watched"))
router.GET("/watchlist", placeholder.NotImplemented("watchlist"))
router.GET("/progress", placeholder.NotImplemented("progress"))
router.POST("/downloads", download.Create)
router.GET("/downloads", download.List)
router.DELETE("/downloads/:id", download.Cancel)
router.GET("/downloads/:id/events", download.Events)
router.GET("/calendar", placeholder.NotImplemented("calendar"))
router.GET("/library", placeholder.NotImplemented("library"))
router.GET("/collections", placeholder.NotImplemented("collections"))
router.GET("/settings", placeholder.NotImplemented("settings"))
router.GET("/admin", placeholder.NotImplemented("admin"))
router.GET("/recommendations", placeholder.NotImplemented("recommendations"))
}
+37
View File
@@ -0,0 +1,37 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/tdvorak/seen/backend/internal/api/handlers"
v1 "github.com/tdvorak/seen/backend/internal/api/routes/v1"
"github.com/tdvorak/seen/backend/internal/config"
"github.com/tdvorak/seen/backend/internal/middleware"
"go.uber.org/zap"
)
type Handlers struct {
Health *handlers.HealthHandler
Auth *handlers.AuthHandler
Catalog *handlers.CatalogHandler
Download *handlers.DownloadHandler
Placeholder *handlers.PlaceholderHandler
}
func NewRouter(cfg config.Config, log *zap.Logger, handlers Handlers) *gin.Engine {
if cfg.Env == "production" {
gin.SetMode(gin.ReleaseMode)
}
engine := gin.New()
engine.Use(middleware.RequestID())
engine.Use(middleware.CORS(cfg.CORS))
engine.Use(middleware.AccessLog(log))
engine.Use(middleware.Recovery(log))
engine.GET("/healthz", handlers.Health.Live)
apiV1 := engine.Group("/api/v1")
v1.Register(apiV1, handlers.Health, handlers.Auth, handlers.Catalog, handlers.Download, handlers.Placeholder)
return engine
}