From c8b549b03ee864914e6ed2a0b714bcab09e654ba Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Wed, 20 May 2026 14:14:29 +0300 Subject: Move serveFileResult and serveRemuxed to api/stream.go (3a) Co-Authored-By: Claude Opus 4.7 --- player-server/internal/api/handlers.go | 61 ---------------------------- player-server/internal/api/stream.go | 74 ++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 61 deletions(-) create mode 100644 player-server/internal/api/stream.go (limited to 'player-server') diff --git a/player-server/internal/api/handlers.go b/player-server/internal/api/handlers.go index 7f05435..093147f 100644 --- a/player-server/internal/api/handlers.go +++ b/player-server/internal/api/handlers.go @@ -9,20 +9,10 @@ import ( "log/slog" "net/http" "strconv" - "time" "codeberg.org/snonux/player/internal/model" - "codeberg.org/snonux/player/internal/service" ) -// fileETag returns a strong ETag value (without surrounding quotes) for a -// file of the given size and modification time. Combining size with mtime -// nanoseconds is enough to detect any in-place rewrite or replacement — -// callers wrap the result in quotes when emitting the header. -func fileETag(size int64, modTime time.Time) string { - return fmt.Sprintf("%d-%d", size, modTime.UnixNano()) -} - // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ @@ -193,54 +183,3 @@ 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) { - // s.streamer is required at construction time (see NewServerWithLogger), - // so it is guaranteed non-nil here. We previously fell back to a default - // streamer when nil, which silently hid wiring mistakes and violated the - // Dependency Inversion Principle by letting the handler decide its own - // dependency. - streamer := s.streamer - - 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") - // Strong ETag derived from size and mtime nanoseconds. http.ServeContent - // reads If-None-Match / If-Match from the request once ETag is set, so - // clients (iOS audio player, podcast clients) can revalidate cached - // downloads without re-fetching the full body. - w.Header().Set("ETag", fmt.Sprintf("%q", fileETag(stream.Size, stream.ModTime))) - 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/player-server/internal/api/stream.go b/player-server/internal/api/stream.go new file mode 100644 index 0000000..422afd3 --- /dev/null +++ b/player-server/internal/api/stream.go @@ -0,0 +1,74 @@ +package api + +import ( + "fmt" + "net/http" + "time" + + "codeberg.org/snonux/player/internal/service" +) + +// fileETag returns a strong ETag value (without surrounding quotes) for a +// file of the given size and modification time. Combining size with mtime +// nanoseconds is enough to detect any in-place rewrite or replacement — +// callers wrap the result in quotes when emitting the header. +func fileETag(size int64, modTime time.Time) string { + return fmt.Sprintf("%d-%d", size, modTime.UnixNano()) +} + +// serveFileResult opens a stream for the given FileResult and writes it to the +// response. If the file requires remuxing (e.g. format conversion), it +// delegates to serveRemuxed; otherwise it uses http.ServeContent for efficient +// range-request support and ETag-based revalidation. +// +// s.streamer is required at construction time (see NewServerWithLogger), so it +// is guaranteed non-nil here. We previously fell back to a default streamer +// when nil, which silently hid wiring mistakes and violated the Dependency +// Inversion Principle by letting the handler decide its own dependency. +func (s *Server) serveFileResult(w http.ResponseWriter, r *http.Request, res *service.FileResult, attachment bool) { + streamer := s.streamer + + 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") + // Strong ETag derived from size and mtime nanoseconds. http.ServeContent + // reads If-None-Match / If-Match from the request once ETag is set, so + // clients (iOS audio player, podcast clients) can revalidate cached + // downloads without re-fetching the full body. + w.Header().Set("ETag", fmt.Sprintf("%q", fileETag(stream.Size, stream.ModTime))) + 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) +} + +// serveRemuxed writes a remuxed (format-converted) media stream directly to +// the response writer. It sets appropriate headers (Content-Type, +// Cache-Control, and optionally X-Duration) and streams the output of the +// remux operation. Errors during remuxing are logged but cannot be propagated +// as headers may already have been written. +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) + } +} -- cgit v1.2.3