summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-rw-r--r--internal/api/handlers.go193
-rw-r--r--internal/api/handlers_admin.go146
-rw-r--r--internal/api/handlers_auth.go126
-rw-r--r--internal/api/handlers_config.go17
-rw-r--r--internal/api/handlers_file.go109
-rw-r--r--internal/api/handlers_media.go538
-rw-r--r--internal/api/handlers_more_test.go2191
-rw-r--r--internal/api/handlers_podcast.go161
-rw-r--r--internal/api/handlers_podcast_test.go370
-rw-r--r--internal/api/handlers_share.go215
-rw-r--r--internal/api/handlers_test.go1673
-rw-r--r--internal/api/middleware.go116
-rw-r--r--internal/api/server.go296
-rw-r--r--internal/api/server_test.go19
14 files changed, 0 insertions, 6170 deletions
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
deleted file mode 100644
index b276b10..0000000
--- a/internal/api/handlers.go
+++ /dev/null
@@ -1,193 +0,0 @@
-package api
-
-import (
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "log/slog"
- "net/http"
- "strconv"
-
- "codeberg.org/snonux/player/internal/model"
- "codeberg.org/snonux/player/internal/service"
-)
-
-// ------------------------------------------------------------------
-// Helpers
-// ------------------------------------------------------------------
-
-func writeJSON(w http.ResponseWriter, status int, data interface{}) {
- w.Header().Set("Content-Type", "application/json")
- w.WriteHeader(status)
- if err := json.NewEncoder(w).Encode(data); err != nil {
- slog.Error("encode json", "err", err)
- }
-}
-
-func writeError(w http.ResponseWriter, status int, message string) {
- writeJSON(w, status, map[string]string{"error": message})
-}
-
-func badRequest(w http.ResponseWriter, message string) {
- writeError(w, http.StatusBadRequest, message)
-}
-
-func notFound(w http.ResponseWriter) {
- writeError(w, http.StatusNotFound, "not found")
-}
-
-func forbidden(w http.ResponseWriter, message string) {
- writeError(w, http.StatusForbidden, message)
-}
-
-// handleError maps service sentinel errors to the appropriate HTTP status
-// and writes a JSON error response. It falls back to 500 for unknown errors.
-func handleError(w http.ResponseWriter, err error) {
- switch {
- case errors.Is(err, service.ErrNotFound),
- errors.Is(err, service.ErrShareNotFound),
- errors.Is(err, service.ErrMediaNotFound):
- notFound(w)
- case errors.Is(err, service.ErrForbidden):
- forbidden(w, "forbidden")
- case errors.Is(err, service.ErrAlreadyBootstrapped):
- forbidden(w, "bootstrap already complete")
- case errors.Is(err, service.ErrInvalidCredentials):
- writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
- case errors.Is(err, service.ErrUnsupportedExtension),
- errors.Is(err, service.ErrInvalidFeed),
- errors.Is(err, service.ErrCannotDeleteSelf):
- badRequest(w, err.Error())
- default:
- writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
- }
-}
-
-func readJSON(r *http.Request, dst interface{}) error {
- if r.Body == nil {
- return errors.New("missing body")
- }
- defer r.Body.Close()
- return json.NewDecoder(r.Body).Decode(dst)
-}
-
-func pathID(r *http.Request, name string) int64 {
- id, _ := strconv.ParseInt(r.PathValue(name), 10, 64)
- return id
-}
-
-func userIDFromContext(r *http.Request) int64 {
- sess, _ := r.Context().Value(sessionCtxKey).(*model.Session)
- if sess == nil {
- return 0
- }
- return sess.UserID
-}
-
-func sessionIDFromContext(r *http.Request) string {
- sess, _ := r.Context().Value(sessionCtxKey).(*model.Session)
- if sess == nil {
- return ""
- }
- return sess.ID
-}
-
-func stringPtr(s string) *string { return &s }
-
-func floatPtr(f float64) *float64 { return &f }
-
-func intPtr(i int64) *int64 { return &i }
-
-func requireService(w http.ResponseWriter, svc any) bool {
- if svc == nil {
- writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "not implemented"})
- return false
- }
- return true
-}
-
-// ------------------------------------------------------------------
-// Static pages
-// ------------------------------------------------------------------
-
-func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, filename string) {
- f, err := s.staticFS.Open(filename)
- if err != nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- defer f.Close()
- stat, err := f.Stat()
- if err != nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- rs, ok := f.(io.ReadSeeker)
- if !ok {
- http.Error(w, "internal error", http.StatusInternalServerError)
- return
- }
- http.ServeContent(w, r, filename, stat.ModTime(), rs)
-}
-
-func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
- s.serveFile(w, r, "index.html")
-}
-
-func (s *Server) serveLogin(w http.ResponseWriter, r *http.Request) {
- s.serveFile(w, r, "login.html")
-}
-
-func (s *Server) serveBootstrap(w http.ResponseWriter, r *http.Request) {
- s.serveFile(w, r, "bootstrap.html")
-}
-
-func (s *Server) serveDetach(w http.ResponseWriter, r *http.Request) {
- s.serveFile(w, r, "detach.html")
-}
-
-// ------------------------------------------------------------------
-// File serving helpers
-// ------------------------------------------------------------------
-
-func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *service.FileResult, attachment bool) {
- streamer := s.streamer
- if streamer == nil {
- streamer = service.NewMediaStreamer(nil)
- }
-
- stream, err := streamer.Open(r.Context(), res, attachment)
- if err != nil {
- s.logger.Warn("api stream open failed", "file", res.FileName, "err", err)
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- defer stream.File.Close()
-
- if stream.Remuxed {
- s.serveRemuxed(w, r, streamer, stream)
- return
- }
-
- if attachment {
- disp := fmt.Sprintf("attachment; filename=%q", res.FileName)
- w.Header().Set("Content-Disposition", disp)
- }
- w.Header().Set("Content-Type", stream.ContentType)
- w.Header().Set("Accept-Ranges", "bytes")
- s.logger.Info("api stream file", "file", stream.FileName, "size", stream.Size, "range", r.Header.Get("Range"))
- http.ServeContent(w, r, stream.FileName, stream.ModTime, stream.File)
-}
-
-func (s *Server) serveRemuxed(w http.ResponseWriter, r *http.Request, streamer service.MediaStreamer, stream *service.StreamResult) {
- w.Header().Set("Content-Type", stream.ContentType)
- w.Header().Set("Cache-Control", "no-store")
- if stream.Duration > 0 {
- w.Header().Set("X-Duration", fmt.Sprintf("%f", stream.Duration))
- }
- s.logger.Info("api remux stream file", "file", stream.FileName, "size", stream.Size, "range", r.Header.Get("Range"))
- if err := streamer.Remux(r.Context(), stream, w); err != nil {
- s.logger.Error("remux media", "file", stream.FileName, "err", err)
- }
-}
diff --git a/internal/api/handlers_admin.go b/internal/api/handlers_admin.go
deleted file mode 100644
index d68b07c..0000000
--- a/internal/api/handlers_admin.go
+++ /dev/null
@@ -1,146 +0,0 @@
-package api
-
-import (
- "net/http"
-
- "codeberg.org/snonux/player/internal/model"
-)
-
-// ------------------------------------------------------------------
-// Admin
-// ------------------------------------------------------------------
-
-func (s *Server) handleListTrash(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- items, err := s.adminSvc.ListTrash(r.Context())
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, items)
-}
-
-func (s *Server) handleRescan(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- if err := s.adminSvc.TriggerRescan(r.Context()); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleScanProgress(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- writeJSON(w, http.StatusOK, s.adminSvc.ScanProgress(r.Context()))
-}
-
-func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- users, err := s.adminSvc.ListUsers(r.Context())
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, users)
-}
-
-func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- var req struct {
- Username string `json:"username"`
- Password string `json:"password"`
- IsAdmin bool `json:"is_admin"`
- }
- if err := readJSON(r, &req); err != nil || req.Username == "" || req.Password == "" {
- badRequest(w, "invalid request")
- return
- }
- user, err := s.adminSvc.CreateUser(r.Context(), req.Username, req.Password, req.IsAdmin)
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, user)
-}
-
-func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid user id")
- return
- }
- adminUser, _ := r.Context().Value(userCtxKey).(*model.User)
- var callerID int64
- if adminUser != nil {
- callerID = adminUser.ID
- }
- if err := s.adminSvc.DeleteUser(r.Context(), callerID, id); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleListPermissions(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- perms, err := s.adminSvc.ListPermissions(r.Context())
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, perms)
-}
-
-func (s *Server) handleGrantPermission(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- var req struct {
- SetID int64 `json:"set_id"`
- UserID int64 `json:"user_id"`
- Role model.Role `json:"role"`
- }
- if err := readJSON(r, &req); err != nil {
- badRequest(w, "invalid request")
- return
- }
- if err := s.adminSvc.GrantPermission(r.Context(), req.SetID, req.UserID, req.Role); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleRevokePermission(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.adminSvc) {
- return
- }
- var req struct {
- SetID int64 `json:"set_id"`
- UserID int64 `json:"user_id"`
- }
- if err := readJSON(r, &req); err != nil {
- badRequest(w, "invalid request")
- return
- }
- if err := s.adminSvc.RevokePermission(r.Context(), req.SetID, req.UserID); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go
deleted file mode 100644
index ed6b3e9..0000000
--- a/internal/api/handlers_auth.go
+++ /dev/null
@@ -1,126 +0,0 @@
-package api
-
-import (
- "errors"
- "fmt"
- "net/http"
- "time"
-
- "codeberg.org/snonux/player/internal/service"
-)
-
-type bootstrapRequest struct {
- Username string `json:"username"`
- Password string `json:"password"`
-}
-
-type loginRequest struct {
- Username string `json:"username"`
- Password string `json:"password"`
-}
-
-// ------------------------------------------------------------------
-// Bootstrap & Auth
-// ------------------------------------------------------------------
-
-func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.authSvc) {
- return
- }
- var req bootstrapRequest
- if err := readJSON(r, &req); err != nil {
- badRequest(w, "invalid request body")
- return
- }
- if req.Username == "" || req.Password == "" {
- badRequest(w, "username and password required")
- return
- }
-
- res, err := s.authSvc.Bootstrap(r.Context(), req.Username, req.Password)
- if err != nil {
- if errors.Is(err, service.ErrAlreadyBootstrapped) {
- forbidden(w, "bootstrap already complete")
- return
- }
- handleError(w, fmt.Errorf("internal server error: %w", err))
- return
- }
-
- s.setSessionCookie(w, res.SessionID)
- writeJSON(w, http.StatusOK, map[string]interface{}{"id": res.User.ID, "username": res.User.Username, "is_admin": res.User.IsAdmin})
-}
-
-func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.authSvc) {
- return
- }
- var req loginRequest
- if err := readJSON(r, &req); err != nil {
- badRequest(w, "invalid request body")
- return
- }
- if req.Username == "" || req.Password == "" {
- badRequest(w, "username and password required")
- return
- }
-
- res, err := s.authSvc.Login(r.Context(), req.Username, req.Password)
- if err != nil {
- if errors.Is(err, service.ErrInvalidCredentials) {
- writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
- return
- }
- handleError(w, fmt.Errorf("internal server error: %w", err))
- return
- }
-
- s.setSessionCookie(w, res.SessionID)
- writeJSON(w, http.StatusOK, map[string]interface{}{"id": res.User.ID, "username": res.User.Username, "is_admin": res.User.IsAdmin})
-}
-
-func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
- cookie, err := r.Cookie("session")
- if err == nil && cookie.Value != "" {
- _ = s.sm.DeleteSession(r.Context(), cookie.Value)
- }
- s.clearSessionCookie(w)
- w.WriteHeader(http.StatusNoContent)
-}
-
-func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
- w.WriteHeader(http.StatusOK)
-}
-
-func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
- if err := s.pingStore(r.Context()); err != nil {
- http.Error(w, "not ready", http.StatusServiceUnavailable)
- return
- }
- w.WriteHeader(http.StatusOK)
-}
-
-func (s *Server) setSessionCookie(w http.ResponseWriter, value string) {
- http.SetCookie(w, &http.Cookie{
- Name: "session",
- Value: value,
- Path: "/",
- HttpOnly: true,
- Secure: s.cfg.SecureCookies,
- SameSite: http.SameSiteStrictMode,
- Expires: time.Now().Add(time.Duration(s.cfg.SessionTimeoutHours) * time.Hour),
- })
-}
-
-func (s *Server) clearSessionCookie(w http.ResponseWriter) {
- http.SetCookie(w, &http.Cookie{
- Name: "session",
- Value: "",
- Path: "/",
- HttpOnly: true,
- Secure: s.cfg.SecureCookies,
- SameSite: http.SameSiteStrictMode,
- MaxAge: -1,
- Expires: time.Unix(0, 0),
- })
-}
diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go
deleted file mode 100644
index 0fdefe7..0000000
--- a/internal/api/handlers_config.go
+++ /dev/null
@@ -1,17 +0,0 @@
-package api
-
-import (
- "net/http"
-
- "codeberg.org/snonux/player/internal"
-)
-
-func (s *Server) handleConfig(w http.ResponseWriter, _ *http.Request) {
- pageSize := internal.DefaultMediaPageSize
- if s.cfg != nil && s.cfg.MediaPageSize > 0 {
- pageSize = s.cfg.MediaPageSize
- }
- writeJSON(w, http.StatusOK, map[string]int{
- "media_page_size": pageSize,
- })
-}
diff --git a/internal/api/handlers_file.go b/internal/api/handlers_file.go
deleted file mode 100644
index ecf55e0..0000000
--- a/internal/api/handlers_file.go
+++ /dev/null
@@ -1,109 +0,0 @@
-package api
-
-import (
- "context"
- "errors"
- "net/http"
-
- "codeberg.org/snonux/player/internal/service"
-)
-
-// ------------------------------------------------------------------
-// File serving handlers
-// ------------------------------------------------------------------
-
-func (s *Server) fileHandler(fn func(context.Context, int64, int64) (*service.FileResult, error)) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
- id := pathID(r, "id")
- if id == 0 {
- http.Error(w, "invalid media id", http.StatusBadRequest)
- return
- }
- res, err := fn(r.Context(), id, userIDFromContext(r))
- if err != nil {
- if errors.Is(err, service.ErrNotFound) {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- http.Error(w, "forbidden", http.StatusForbidden)
- return
- }
- http.Error(w, err.Error(), http.StatusInternalServerError)
- return
- }
- if res == nil {
- http.Error(w, "not found", http.StatusNotFound)
- return
- }
- s.serveFileResult(w, r, res, false)
- }
-}
-
-func (s *Server) handleStream(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- s.fileHandler(s.browseSvc.StreamMedia)(w, r)
-}
-
-func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- res, err := s.browseSvc.DownloadMedia(r.Context(), id, userIDFromContext(r))
- if err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- if res == nil {
- notFound(w)
- return
- }
- s.serveFileResult(w, r, res, true)
-}
-
-func (s *Server) handleThumbnail(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- w.Header().Set("Cache-Control", "no-cache")
- s.fileHandler(s.browseSvc.GetThumbnail)(w, r)
-}
-
-func (s *Server) handleRegenThumbnail(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.writeSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- if err := s.writeSvc.RegenerateThumbnail(r.Context(), id, userIDFromContext(r)); err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
diff --git a/internal/api/handlers_media.go b/internal/api/handlers_media.go
deleted file mode 100644
index 5d7a970..0000000
--- a/internal/api/handlers_media.go
+++ /dev/null
@@ -1,538 +0,0 @@
-package api
-
-import (
- "errors"
- "net/http"
- "net/url"
- "strconv"
- "strings"
- "time"
-
- "codeberg.org/snonux/player/internal/model"
- "codeberg.org/snonux/player/internal/service"
-)
-
-const multipartFormMemoryLimit = 32 << 20
-
-// ------------------------------------------------------------------
-// Sets
-// ------------------------------------------------------------------
-
-func (s *Server) handleListSets(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- sets, err := s.browseSvc.ListSets(r.Context(), userIDFromContext(r))
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, sets)
-}
-
-func (s *Server) handleGetSetCover(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- setID := pathID(r, "id")
- if setID == 0 {
- badRequest(w, "invalid set id")
- return
- }
- folder := r.URL.Query().Get("folder")
- fr, err := s.browseSvc.GetSetCover(r.Context(), setID, folder, userIDFromContext(r))
- if err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- w.Header().Set("Cache-Control", "no-cache")
- http.ServeFile(w, r, fr.Path)
-}
-
-func (s *Server) handlePostSetCover(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.writeSvc) {
- return
- }
- setID := pathID(r, "id")
- if setID == 0 {
- badRequest(w, "invalid set id")
- return
- }
- folder := r.URL.Query().Get("folder")
- if err := s.writeSvc.RegenerateSetCover(r.Context(), setID, folder, userIDFromContext(r)); err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleBrowseSet(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- setID := pathID(r, "id")
- if setID == 0 {
- badRequest(w, "invalid set id")
- return
- }
- parent := r.URL.Query().Get("parent")
- result, err := s.browseSvc.BrowseSet(r.Context(), setID, userIDFromContext(r), parent)
- if err != nil {
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, result)
-}
-
-func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.writeSvc) {
- return
- }
- setID := pathID(r, "id")
- if setID == 0 {
- badRequest(w, "invalid set id")
- return
- }
-
- maxBytes := int64(s.cfg.MaxUploadSizeMB) << 20
- r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
- if err := r.ParseMultipartForm(multipartFormMemoryLimit); err != nil {
- var mbe *http.MaxBytesError
- if errors.As(err, &mbe) {
- writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "file too large"})
- return
- }
- badRequest(w, "invalid multipart form")
- return
- }
- defer r.MultipartForm.RemoveAll()
-
- file, fh, err := r.FormFile("file")
- if err != nil {
- badRequest(w, "missing file")
- return
- }
- defer file.Close()
-
- media, err := s.writeSvc.UploadMedia(r.Context(), setID, userIDFromContext(r), fh.Filename, file, fh.Size)
- if err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- if errors.Is(err, service.ErrUnsupportedExtension) {
- badRequest(w, err.Error())
- return
- }
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, media)
-}
-
-// ------------------------------------------------------------------
-// Media
-// ------------------------------------------------------------------
-
-// parseMediaListQuery extracts and validates query parameters from the request
-// and returns a populated service.MediaQueryFilter with sensible defaults.
-func parseMediaListQuery(q url.Values) service.MediaQueryFilter {
- filter := service.MediaQueryFilter{
- Search: q.Get("search"),
- Sort: q.Get("sort"),
- Limit: 100,
- Offset: 0,
- }
- if v := q.Get("set_id"); v != "" {
- if id, err := strconv.ParseInt(v, 10, 64); err == nil {
- filter.SetID = &id
- }
- }
- if v := q.Get("set_ids"); v != "" {
- parts := strings.Split(v, ",")
- for _, p := range parts {
- if id, err := strconv.ParseInt(strings.TrimSpace(p), 10, 64); err == nil {
- filter.SetIDs = append(filter.SetIDs, id)
- }
- }
- }
- if v := q.Get("type"); v != "" {
- t := model.MediaType(v)
- filter.Type = &t
- }
- if v := q.Get("favorites"); v == "true" || v == "1" {
- filter.Favorites = true
- }
- if v := q.Get("tags"); v != "" {
- filter.Tags = strings.Split(v, ",")
- }
- if v := q.Get("min_duration"); v != "" {
- if f, err := strconv.ParseFloat(v, 64); err == nil {
- filter.MinDuration = &f
- }
- }
- if v := q.Get("max_duration"); v != "" {
- if f, err := strconv.ParseFloat(v, 64); err == nil {
- filter.MaxDuration = &f
- }
- }
- if v := q.Get("filesize_min"); v != "" {
- if n, err := strconv.ParseInt(v, 10, 64); err == nil {
- filter.MinFileSize = &n
- }
- }
- if v := q.Get("filesize_max"); v != "" {
- if n, err := strconv.ParseInt(v, 10, 64); err == nil {
- filter.MaxFileSize = &n
- }
- }
- if v := q.Get("limit"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 1000 {
- filter.Limit = n
- }
- }
- if v := q.Get("offset"); v != "" {
- if n, err := strconv.Atoi(v); err == nil && n >= 0 {
- filter.Offset = n
- }
- }
- return filter
-}
-
-func (s *Server) handleListMedia(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- path := r.URL.Path
- q := r.URL.Query()
- setID := q.Get("set_id")
- setIDs := q.Get("set_ids")
- search := q.Get("search")
- typ := q.Get("type")
- fav := q.Get("favorites")
- minDur := q.Get("min_duration")
- maxDur := q.Get("max_duration")
- start := time.Now()
- filter := parseMediaListQuery(q)
- media, err := s.browseSvc.ListMedia(r.Context(), userIDFromContext(r), filter)
- dur := time.Since(start)
- if err != nil {
- s.logger.Error("api list media failed", "path", path, "set_id", setID, "set_ids", setIDs, "search", search, "type", typ, "favorites", fav, "min_duration", minDur, "max_duration", maxDur, "duration", dur, "err", err)
- handleError(w, err)
- return
- }
- s.logger.Info("api list media", "path", path, "set_id", setID, "set_ids", setIDs, "search", search, "type", typ, "favorites", fav, "min_duration", minDur, "max_duration", maxDur, "returned", len(media), "duration", dur)
- writeJSON(w, http.StatusOK, media)
-}
-
-func (s *Server) handleGetMedia(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.browseSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- detail, err := s.browseSvc.GetMediaDetail(r.Context(), id, userIDFromContext(r))
- if err != nil {
- handleError(w, err)
- return
- }
- if detail == nil {
- notFound(w)
- return
- }
- writeJSON(w, http.StatusOK, detail)
-}
-
-func (s *Server) handleFavorite(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.favSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- fav, err := s.favSvc.ToggleFavorite(r.Context(), userIDFromContext(r), id)
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]bool{"favorite": fav})
-}
-
-func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.tagSvc) {
- return
- }
- tags, err := s.tagSvc.ListTags(r.Context(), userIDFromContext(r))
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, tags)
-}
-
-func (s *Server) handleAddTag(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.tagSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- var req struct {
- Tag string `json:"tag"`
- }
- if err := readJSON(r, &req); err != nil || req.Tag == "" {
- badRequest(w, "tag required")
- return
- }
- if err := s.tagSvc.AssignTag(r.Context(), id, userIDFromContext(r), req.Tag); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleRemoveTag(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.tagSvc) {
- return
- }
- id := pathID(r, "id")
- tagName := r.PathValue("tag")
- if id == 0 || tagName == "" {
- badRequest(w, "invalid parameters")
- return
- }
- if err := s.tagSvc.RemoveTag(r.Context(), id, userIDFromContext(r), tagName); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleSoftDelete(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.writeSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- if err := s.writeSvc.SoftDeleteMedia(r.Context(), id, userIDFromContext(r)); err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.writeSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- if err := s.writeSvc.RestoreMedia(r.Context(), id, userIDFromContext(r)); err != nil {
- if errors.Is(err, service.ErrNotFound) {
- notFound(w)
- return
- }
- if errors.Is(err, service.ErrForbidden) {
- forbidden(w, "forbidden")
- return
- }
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-// ------------------------------------------------------------------
-// Notes
-// ------------------------------------------------------------------
-
-func (s *Server) handleGetNote(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.noteSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- note, err := s.noteSvc.GetNote(r.Context(), id, userIDFromContext(r))
- if err != nil {
- handleError(w, err)
- return
- }
- if note == nil {
- w.WriteHeader(http.StatusNoContent)
- return
- }
- writeJSON(w, http.StatusOK, note)
-}
-
-func (s *Server) handleUpsertNote(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.noteSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- var req struct {
- Content string `json:"content"`
- }
- if err := readJSON(r, &req); err != nil {
- badRequest(w, "invalid body")
- return
- }
- note := &model.Note{MediaID: id, UserID: userIDFromContext(r), Content: req.Content}
- if err := s.noteSvc.UpsertNote(r.Context(), note); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, note)
-}
-
-func (s *Server) handleDeleteNote(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.noteSvc) {
- return
- }
- id := pathID(r, "id")
- if id == 0 {
- badRequest(w, "invalid media id")
- return
- }
- if err := s.noteSvc.DeleteNote(r.Context(), id, userIDFromContext(r)); err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}
-
-// ------------------------------------------------------------------
-// Progress
-// ------------------------------------------------------------------
-
-func (s *Server) handleProgress(w http.ResponseWriter, r *http.Request) {
- if !requireService(w, s.progressSvc) {
- return
- }
- var req struct {
- MediaID int64 `json:"media_id"`
- Position float64 `json:"position_seconds"`
- }
- if err := readJSON(r, &req); err != nil {
- badRequest(w, "invalid body")
- return
- }
- if req.MediaID == 0 {
- badRequest(w, "media_id required")
- return
- }
- sessionID := sessionIDFromContext(r)
- if sessionID == "" {
- badRequest(w, "session required")
- return
- }
- err := s.progressSvc.UpdateProgress(
- r.Context(),
- sessionID,
- userIDFromContext(r),
- req.MediaID,
- req.Position,
- )
- if err != nil {
- handleError(w, err)
- return
- }
- writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
-}