package media // HLS transcoding for an item: the playlist and its segments. // // The item is loaded through the catalog chokepoint exactly as handleCover does // (ADR-005), so a stream on an unreadable item answers with the same 404 an // absent item does. // // The playlist request starts the encoder lazily (Manager.Start), then serves // whatever ffmpeg has written so far, so a player can begin while the rest is // still encoding. Every request goes through Start, which touches the session // and keeps the idle reaper from stopping it in the middle of a watch. import ( "errors" "net/http" "os" "path/filepath" "regexp" "time" "github.com/mithral/vellum/internal/auth" "github.com/mithral/vellum/internal/httpx" "github.com/mithral/vellum/internal/transcode" ) // Sessions starts (or reuses) the HLS transcode session for one item. // // *transcode.Manager is the production implementation. It is an interface so a // test can inject a fake and run these routes with no hardware and no ffmpeg. type Sessions interface { Start(itemID int64, inputPath, cacheDir string) (transcode.Session, error) } // segmentName is the exact shape ffmpeg's -hls_segment_filename writes: // "seg" then five digits then ".ts". Anything else is refused, so a request can // never name a file outside the session directory. var segmentName = regexp.MustCompile(`^seg[0-9]{5}\.ts$`) // newSessions is the production manager, constructed at the wiring site when // none is injected. func newSessions() Sessions { return transcode.New() } // handleTranscodePlaylist starts the item's session if it is not already // running, then serves the HLS playlist as it stands. func (h *Handlers) handleTranscodePlaylist(w http.ResponseWriter, r *http.Request, u auth.User) { it, ok := h.authorized(w, r, u) if !ok { return } s, ok := h.startSession(w, r, it) if !ok { return } h.serveTranscodeFile(w, r, s.PlaylistPath(), "application/vnd.apple.mpegurl") } // handleTranscodeSegment serves one .ts segment of the item's stream. func (h *Handlers) handleTranscodeSegment(w http.ResponseWriter, r *http.Request, u auth.User) { it, ok := h.authorized(w, r, u) if !ok { return } name := r.PathValue("segment") if !segmentName.MatchString(name) { httpx.WriteError(w, http.StatusNotFound, "segment not found") return } s, ok := h.startSession(w, r, it) if !ok { return } h.serveTranscodeFile(w, r, filepath.Join(s.Dir(), name), "video/mp2t") } // startSession starts or reuses the item's transcode session, translating the // manager's refusals into a status. On failure it has already written the // response. // // Reusing a live session is also the "touch": Start refreshes its idle timer, // so a request for a segment keeps the encoder alive while it is being watched. func (h *Handlers) startSession(w http.ResponseWriter, r *http.Request, it Item) (transcode.Session, bool) { s, err := h.sessions.Start(it.ID, it.AbsPath(), h.cacheDir) switch { case errors.Is(err, transcode.ErrUnavailable): httpx.WriteError(w, http.StatusServiceUnavailable, "transcoding is not available on this server") return nil, false case errors.Is(err, transcode.ErrAtCapacity): httpx.WriteError(w, http.StatusServiceUnavailable, "transcode capacity reached, try again shortly") return nil, false case errors.Is(err, transcode.ErrShutdown): httpx.WriteError(w, http.StatusServiceUnavailable, "transcoding is shutting down") return nil, false case err != nil: h.fail(w, "start transcode", err) return nil, false } return s, true } // serveTranscodeFile serves a file the encoder may still be appending to. It // sizes the response from the file as it is now — not from a finished transcode // — so a player gets the partial playlist immediately. func (h *Handlers) serveTranscodeFile(w http.ResponseWriter, r *http.Request, path, contentType string) { // ffmpeg needs a moment to write the playlist after it is spawned. Waiting // for it here is what makes a plain