summaryrefslogtreecommitdiff
path: root/player-server/internal/api/stream.go
blob: 422afd300ad6b7cab074b816538dfa72584018e9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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)
	}
}