diff options
Diffstat (limited to 'internal/api')
| -rw-r--r-- | internal/api/handlers.go | 193 | ||||
| -rw-r--r-- | internal/api/handlers_admin.go | 146 | ||||
| -rw-r--r-- | internal/api/handlers_auth.go | 126 | ||||
| -rw-r--r-- | internal/api/handlers_config.go | 17 | ||||
| -rw-r--r-- | internal/api/handlers_file.go | 109 | ||||
| -rw-r--r-- | internal/api/handlers_media.go | 538 | ||||
| -rw-r--r-- | internal/api/handlers_more_test.go | 2191 | ||||
| -rw-r--r-- | internal/api/handlers_podcast.go | 161 | ||||
| -rw-r--r-- | internal/api/handlers_podcast_test.go | 370 | ||||
| -rw-r--r-- | internal/api/handlers_share.go | 215 | ||||
| -rw-r--r-- | internal/api/handlers_test.go | 1673 | ||||
| -rw-r--r-- | internal/api/middleware.go | 116 | ||||
| -rw-r--r-- | internal/api/server.go | 296 | ||||
| -rw-r--r-- | internal/api/server_test.go | 19 |
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"}) -} |
