hls.go 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. package transcoder
  2. /*
  3. hls.go
  4. HTTP Live Streaming output for the transcoder.
  5. TranscodeAndStream pipes a single fragmented MP4 down one long-lived HTTP
  6. response, which cannot answer byte-range requests and therefore cannot be
  7. played by WebKit clients (Safari, and every browser on iOS). HLS solves that
  8. by cutting the transcode into short segments listed in a playlist: each
  9. segment is an ordinary, finite, seekable file.
  10. A session owns one ffmpeg process writing segments into its own temp
  11. directory. Sessions are keyed by owner + source file + resolution + start
  12. offset so that a reload, or a second player on the same file, reuses the
  13. transcode already running instead of starting another. Idle sessions are
  14. reaped by a background janitor, which kills ffmpeg and removes the directory.
  15. The playlist is written with -hls_playlist_type event, so it grows as the
  16. transcode proceeds and the player can seek freely within whatever has been
  17. produced so far. Seeking past that point is done the same way the MP4 path
  18. does it: by starting a new session at a later -ss offset.
  19. That last part is why a session carries the id of the player that asked for
  20. it. The MP4 path kills its ffmpeg as soon as the client drops the response,
  21. so a seek never leaves a transcode behind; an HLS session has no such
  22. signal, and without one every seek would add another ffmpeg racing through
  23. the rest of the film. A new session therefore supersedes the ones the same
  24. player left behind (see GetOrCreate).
  25. */
  26. import (
  27. "crypto/md5"
  28. "encoding/hex"
  29. "errors"
  30. "fmt"
  31. "os"
  32. "os/exec"
  33. "path/filepath"
  34. "strconv"
  35. "strings"
  36. "sync"
  37. "time"
  38. "imuslab.com/arozos/mod/info/logger"
  39. )
  40. const (
  41. // hlsSegmentSeconds is the target length of each segment. Four seconds is
  42. // the usual HLS compromise: short enough that playback starts quickly and
  43. // seeking lands close to the requested time, long enough that the segment
  44. // count (and request rate) stays sane on a feature-length file.
  45. hlsSegmentSeconds = 4
  46. hlsPlaylistName = "index.m3u8"
  47. hlsSegmentPattern = "seg%05d.m4s"
  48. hlsSegmentPrefix = "seg"
  49. hlsSegmentSuffix = ".m4s"
  50. // HLSInitSegmentName is the fragmented-MP4 initialisation segment every
  51. // fMP4 playlist points at with #EXT-X-MAP. Exported because the handler
  52. // serving playlists has to rewrite that URI onto the segment endpoint.
  53. HLSInitSegmentName = "init.mp4"
  54. hlsWorkingDirName = "hls"
  55. // hlsIdleTimeout has to outlast a pause: a paused player stops fetching
  56. // segments entirely, and reaping its session under it means the next
  57. // segment request 404s and playback dies with an unexplained decode error.
  58. hlsIdleTimeout = 15 * time.Minute
  59. hlsMaxSessions = 8
  60. hlsJanitorInterval = 30 * time.Second
  61. // HLSPlaylistWaitTimeout bounds how long a playlist request should wait for
  62. // ffmpeg to produce the first segment before giving up. Exported so the
  63. // handler serving playlists uses the same budget the transcode was sized for.
  64. HLSPlaylistWaitTimeout = 45 * time.Second
  65. )
  66. // HLSSession is one running transcode writing HLS segments to disk.
  67. type HLSSession struct {
  68. ID string // opaque identifier, also the temp directory name
  69. Owner string // username allowed to fetch this session's segments
  70. Client string // opaque id of the player that asked for this transcode
  71. Source string // source file being transcoded
  72. Dir string // directory holding the playlist and its segments
  73. StartTime float64 // -ss offset this session was started at, in seconds
  74. cmd *exec.Cmd
  75. stderr *tailBuffer // last few KB of ffmpeg's diagnostics
  76. exited chan struct{} // closed once the transcode process has been reaped
  77. mu sync.Mutex
  78. lastAccess time.Time
  79. stopped bool
  80. }
  81. // tailBuffer keeps the last hlsStderrTailBytes written to it. ffmpeg reports
  82. // why a transcode produced nothing on stderr, and that is the only explanation
  83. // available when a session fails; the whole stream is not worth keeping, but
  84. // the end of it names the reason.
  85. type tailBuffer struct {
  86. mu sync.Mutex
  87. data []byte
  88. }
  89. // hlsStderrTailBytes bounds how much of ffmpeg's stderr is retained per
  90. // session. Enough for the final error plus the surrounding context, small
  91. // enough that eight idle sessions cost nothing worth measuring.
  92. const hlsStderrTailBytes = 4096
  93. func (t *tailBuffer) Write(p []byte) (int, error) {
  94. t.mu.Lock()
  95. defer t.mu.Unlock()
  96. t.data = append(t.data, p...)
  97. if len(t.data) > hlsStderrTailBytes {
  98. t.data = t.data[len(t.data)-hlsStderrTailBytes:]
  99. }
  100. return len(p), nil
  101. }
  102. func (t *tailBuffer) String() string {
  103. t.mu.Lock()
  104. defer t.mu.Unlock()
  105. return string(t.data)
  106. }
  107. // StderrTail returns the end of the transcode's diagnostic output, for logging
  108. // when the session fails to produce anything playable.
  109. func (s *HLSSession) StderrTail() string {
  110. if s.stderr == nil {
  111. return ""
  112. }
  113. return strings.TrimSpace(s.stderr.String())
  114. }
  115. // touch records activity so the janitor does not reap a session that is still
  116. // being played.
  117. func (s *HLSSession) touch() {
  118. s.mu.Lock()
  119. s.lastAccess = time.Now()
  120. s.mu.Unlock()
  121. }
  122. func (s *HLSSession) idleFor() time.Duration {
  123. s.mu.Lock()
  124. defer s.mu.Unlock()
  125. return time.Since(s.lastAccess)
  126. }
  127. // PlaylistPath returns the on-disk location of this session's playlist.
  128. func (s *HLSSession) PlaylistPath() string {
  129. return filepath.Join(s.Dir, hlsPlaylistName)
  130. }
  131. // SegmentPath resolves a segment name from a playlist entry to a path inside
  132. // this session's directory. The name is validated rather than merely cleaned:
  133. // it must look exactly like a name this session generated, so a request can
  134. // never address a file outside the session directory.
  135. func (s *HLSSession) SegmentPath(name string) (string, error) {
  136. if !validHLSSegmentName(name) {
  137. return "", errors.New("invalid segment name")
  138. }
  139. return filepath.Join(s.Dir, name), nil
  140. }
  141. // stop kills the transcode and removes the session's directory. Safe to call
  142. // more than once.
  143. //
  144. // Only the goroutine started in GetOrCreate ever calls cmd.Wait; this waits on
  145. // the channel that goroutine closes instead, since calling Wait twice on the
  146. // same command is an error.
  147. func (s *HLSSession) stop() {
  148. s.mu.Lock()
  149. if s.stopped {
  150. s.mu.Unlock()
  151. return
  152. }
  153. s.stopped = true
  154. cmd := s.cmd
  155. exited := s.exited
  156. dir := s.Dir
  157. s.mu.Unlock()
  158. if cmd != nil && cmd.Process != nil {
  159. cmd.Process.Kill()
  160. if exited != nil {
  161. <-exited
  162. }
  163. }
  164. if dir != "" {
  165. os.RemoveAll(dir)
  166. }
  167. }
  168. // validHLSSegmentName reports whether name matches the segment naming this
  169. // package generates ("seg00000.m4s", plus the fixed init segment name), rejecting anything containing a path
  170. // separator, "..", or unexpected characters.
  171. func validHLSSegmentName(name string) bool {
  172. // The fMP4 initialisation segment is fetched through the same endpoint as
  173. // the media segments, so it has to be accepted here too. Matching the exact
  174. // constant keeps the guarantee that only generated names resolve.
  175. if name == HLSInitSegmentName {
  176. return true
  177. }
  178. if !strings.HasPrefix(name, hlsSegmentPrefix) || !strings.HasSuffix(name, hlsSegmentSuffix) {
  179. return false
  180. }
  181. digits := strings.TrimSuffix(strings.TrimPrefix(name, hlsSegmentPrefix), hlsSegmentSuffix)
  182. if digits == "" {
  183. return false
  184. }
  185. for _, c := range digits {
  186. if c < '0' || c > '9' {
  187. return false
  188. }
  189. }
  190. return true
  191. }
  192. // hlsSessionKey identifies a reusable transcode. Two requests that would
  193. // produce byte-identical output share a session.
  194. func hlsSessionKey(owner string, inputFile string, resolution TranscodeOutputResolution, startTime float64) string {
  195. raw := strings.Join([]string{
  196. owner,
  197. inputFile,
  198. string(resolution),
  199. strconv.FormatFloat(startTime, 'f', 3, 64),
  200. }, "\x00")
  201. sum := md5.Sum([]byte(raw))
  202. return hex.EncodeToString(sum[:])
  203. }
  204. // buildHLSArgs assembles the ffmpeg command line for an HLS session. It mirrors
  205. // the encoder selection in TranscodeAndStream - the same hardware profile when
  206. // one is available, libx264 otherwise - and differs only in the muxer.
  207. //
  208. // segmentBaseURL is prepended to every segment name in the playlist, letting the
  209. // segments be fetched from an HTTP endpoint rather than sitting next to the
  210. // playlist on disk.
  211. func buildHLSArgs(inputFile string, dir string, resolution TranscodeOutputResolution, startTime float64, segmentBaseURL string, hw *hwEncoderProfile) ([]string, error) {
  212. height, err := resolutionHeight(resolution)
  213. if err != nil {
  214. return nil, err
  215. }
  216. var args []string
  217. var vf string
  218. var videoCodecArgs []string
  219. if hw != nil {
  220. args = append(args, hw.PreInput...)
  221. vf = hw.ScaleFilter(height)
  222. videoCodecArgs = append([]string{"-vcodec", hw.Codec}, hw.EncodeArgs...)
  223. } else {
  224. if height != "" {
  225. vf = "scale=-1:" + height
  226. }
  227. // See TranscodeAndStream: without this a 10-bit source yields a High 10
  228. // stream that most browsers cannot decode.
  229. videoCodecArgs = []string{"-vcodec", "libx264", "-preset", "superfast", "-pix_fmt", "yuv420p"}
  230. }
  231. if startTime > 0.001 {
  232. // Seeking before -i is the fast path: output timestamps then start at
  233. // zero, which is what the growing playlist expects.
  234. args = append(args, "-ss", fmt.Sprintf("%.3f", startTime))
  235. }
  236. args = append(args, "-i", inputFile)
  237. // Take the first video and, if present, the first audio track. Without this
  238. // a file carrying extra streams (subtitles, attachments, second audio) can
  239. // fail to mux into the fMP4 segments, or put a track the player does not
  240. // expect into them.
  241. args = append(args, "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn")
  242. if vf != "" {
  243. args = append(args, "-vf", vf)
  244. }
  245. args = append(args, videoCodecArgs...)
  246. // Segments can only be cut on a keyframe, so force one exactly on every
  247. // segment boundary; otherwise ffmpeg overshoots and segment lengths drift
  248. // away from hlsSegmentSeconds.
  249. args = append(args,
  250. "-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", hlsSegmentSeconds),
  251. "-c:a", "aac", "-b:a", "128k", "-ac", "2",
  252. )
  253. args = append(args,
  254. "-f", "hls",
  255. "-hls_time", strconv.Itoa(hlsSegmentSeconds),
  256. "-hls_list_size", "0", // keep every segment in the playlist so seeking back works
  257. "-hls_playlist_type", "event",
  258. "-hls_flags", "independent_segments",
  259. // Fragmented MP4 rather than MPEG-TS. Safari plays either, but fMP4
  260. // segments can be appended straight into a MediaSource buffer, which is
  261. // what lets Firefox and Chrome play this stream without a third-party
  262. // library to demux transport-stream packets first.
  263. "-hls_segment_type", "fmp4",
  264. "-hls_fmp4_init_filename", HLSInitSegmentName,
  265. "-hls_base_url", segmentBaseURL,
  266. "-hls_segment_filename", hlsOutputPath(dir, hlsSegmentPattern),
  267. hlsOutputPath(dir, hlsPlaylistName),
  268. )
  269. return args, nil
  270. }
  271. // hlsOutputPath builds an output path for the HLS muxer with forward slashes.
  272. //
  273. // ffmpeg places the fMP4 init segment next to the playlist by cutting the
  274. // playlist path at its last '/', and only '/'. Given a native Windows path
  275. // (backslashes) it finds no directory, so init.mp4 lands in ffmpeg's working
  276. // directory instead of the session's, every session overwrites the same file,
  277. // and the player's first request - the init segment - 404s. ffmpeg on Windows
  278. // accepts forward slashes, and on every other OS this changes nothing.
  279. func hlsOutputPath(dir string, name string) string {
  280. return filepath.ToSlash(filepath.Join(dir, name))
  281. }
  282. // resolutionHeight maps a requested output resolution to an ffmpeg scale
  283. // height, returning an error for anything unrecognised.
  284. func resolutionHeight(resolution TranscodeOutputResolution) (string, error) {
  285. switch resolution {
  286. case "360p":
  287. return "360", nil
  288. case "720p":
  289. return "720", nil
  290. case "1080p":
  291. return "1080", nil
  292. case "":
  293. return "", nil
  294. }
  295. return "", errors.New("invalid resolution parameter")
  296. }
  297. // HLSManager owns every live HLS session and the temp directory they live in.
  298. type HLSManager struct {
  299. root string // parent directory for all session directories
  300. segmentEndpoint string // URL path that serves segments back to the player
  301. mu sync.Mutex
  302. sessions map[string]*HLSSession
  303. closed bool
  304. stopChan chan struct{}
  305. }
  306. // NewHLSManager prepares the working directory and starts the reaper. Any
  307. // directory left behind by a previous run is discarded, since the ffmpeg
  308. // processes that were filling those directories died with that run.
  309. func NewHLSManager(tmpDirectory string, segmentEndpoint string) (*HLSManager, error) {
  310. root := filepath.Join(tmpDirectory, hlsWorkingDirName)
  311. os.RemoveAll(root)
  312. if err := os.MkdirAll(root, 0755); err != nil {
  313. return nil, err
  314. }
  315. m := &HLSManager{
  316. root: root,
  317. segmentEndpoint: segmentEndpoint,
  318. sessions: map[string]*HLSSession{},
  319. stopChan: make(chan struct{}),
  320. }
  321. go m.janitor()
  322. return m, nil
  323. }
  324. // janitor reaps sessions nobody has touched for hlsIdleTimeout.
  325. func (m *HLSManager) janitor() {
  326. ticker := time.NewTicker(hlsJanitorInterval)
  327. defer ticker.Stop()
  328. for {
  329. select {
  330. case <-m.stopChan:
  331. return
  332. case <-ticker.C:
  333. m.reapIdle()
  334. }
  335. }
  336. }
  337. func (m *HLSManager) reapIdle() {
  338. m.mu.Lock()
  339. var expired []*HLSSession
  340. for key, session := range m.sessions {
  341. if session.idleFor() > hlsIdleTimeout {
  342. expired = append(expired, session)
  343. delete(m.sessions, key)
  344. }
  345. }
  346. m.mu.Unlock()
  347. for _, session := range expired {
  348. session.stop()
  349. logger.PrintAndLog("Transcoder", "Reaped idle HLS session "+session.ID, nil)
  350. }
  351. }
  352. // Close stops the janitor and tears down every live session.
  353. func (m *HLSManager) Close() {
  354. m.mu.Lock()
  355. if m.closed {
  356. m.mu.Unlock()
  357. return
  358. }
  359. m.closed = true
  360. close(m.stopChan)
  361. sessions := make([]*HLSSession, 0, len(m.sessions))
  362. for key, session := range m.sessions {
  363. sessions = append(sessions, session)
  364. delete(m.sessions, key)
  365. }
  366. m.mu.Unlock()
  367. for _, session := range sessions {
  368. session.stop()
  369. }
  370. os.RemoveAll(m.root)
  371. }
  372. // Session returns a live session by ID, or nil. It counts as activity, so
  373. // fetching segments keeps the session from being reaped mid-playback.
  374. func (m *HLSManager) Session(id string) *HLSSession {
  375. m.mu.Lock()
  376. defer m.mu.Unlock()
  377. for _, session := range m.sessions {
  378. if session.ID == id {
  379. session.touch()
  380. return session
  381. }
  382. }
  383. return nil
  384. }
  385. // GetOrCreate returns the session for this exact transcode, starting one if it
  386. // is not already running.
  387. //
  388. // client identifies the player asking, so that a seek - which arrives as a
  389. // request for the same file at a different offset - retires the transcode it
  390. // replaces instead of leaving it running. Pass an empty string when there is no
  391. // such id; the previous transcode of the same file for the same user is then
  392. // treated as the one being replaced.
  393. func (m *HLSManager) GetOrCreate(owner string, client string, inputFile string, resolution TranscodeOutputResolution, startTime float64) (*HLSSession, error) {
  394. key := hlsSessionKey(owner, inputFile, resolution, startTime)
  395. m.mu.Lock()
  396. if m.closed {
  397. m.mu.Unlock()
  398. return nil, errors.New("HLS manager closed")
  399. }
  400. if existing, ok := m.sessions[key]; ok {
  401. existing.touch()
  402. m.mu.Unlock()
  403. return existing, nil
  404. }
  405. m.mu.Unlock()
  406. // Make room before starting another transcode; each one costs a process.
  407. m.evictToCapacity()
  408. session := &HLSSession{
  409. ID: key,
  410. Owner: owner,
  411. Client: client,
  412. Source: inputFile,
  413. Dir: filepath.Join(m.root, key),
  414. StartTime: startTime,
  415. stderr: &tailBuffer{},
  416. lastAccess: time.Now(),
  417. }
  418. if err := os.MkdirAll(session.Dir, 0755); err != nil {
  419. return nil, err
  420. }
  421. args, err := buildHLSArgs(inputFile, session.Dir, resolution, startTime,
  422. m.segmentBaseURL(key), getHWEncoderProfile())
  423. if err != nil {
  424. os.RemoveAll(session.Dir)
  425. return nil, err
  426. }
  427. cmd := exec.Command("ffmpeg", args...)
  428. //Kept rather than discarded: when a transcode produces no playable segment,
  429. //ffmpeg's own message is the only thing that says why.
  430. cmd.Stderr = session.stderr
  431. if err := cmd.Start(); err != nil {
  432. os.RemoveAll(session.Dir)
  433. return nil, err
  434. }
  435. session.cmd = cmd
  436. session.exited = make(chan struct{})
  437. //Sole owner of cmd.Wait: reaps the process whether it finishes the file or
  438. //is killed, and signals both facts through the same channel.
  439. go func(exited chan struct{}) {
  440. cmd.Wait()
  441. close(exited)
  442. }(session.exited)
  443. m.mu.Lock()
  444. // Another request may have created the same session while ffmpeg was
  445. // starting; keep the winner and discard this duplicate.
  446. if existing, ok := m.sessions[key]; ok {
  447. m.mu.Unlock()
  448. session.stop()
  449. existing.touch()
  450. return existing, nil
  451. }
  452. m.sessions[key] = session
  453. superseded := m.collectSuperseded(key, session)
  454. m.mu.Unlock()
  455. for _, old := range superseded {
  456. old.stop()
  457. logger.PrintAndLog("Transcoder", "Retired superseded HLS session "+old.ID, nil)
  458. }
  459. return session, nil
  460. }
  461. // collectSuperseded removes and returns the sessions the newly created one
  462. // replaces: the same player's earlier transcodes, or - when the player did not
  463. // identify itself - the same user's earlier transcodes of the same file.
  464. //
  465. // Must be called with m.mu held; the returned sessions are stopped by the
  466. // caller once the lock is released, since stopping waits on a process.
  467. func (m *HLSManager) collectSuperseded(newKey string, session *HLSSession) []*HLSSession {
  468. var superseded []*HLSSession
  469. for key, candidate := range m.sessions {
  470. if key == newKey {
  471. continue
  472. }
  473. if candidate.Owner != session.Owner {
  474. continue
  475. }
  476. if session.Client != "" {
  477. //A player only ever plays one thing at a time, so anything else it
  478. //started - another offset, or the previous episode - is finished with.
  479. if candidate.Client != session.Client {
  480. continue
  481. }
  482. } else if candidate.Client != "" || candidate.Source != session.Source {
  483. //Without an id the most that can be assumed is that the same user
  484. //re-opened the same file. Never retire a session that does belong to
  485. //an identified player.
  486. continue
  487. }
  488. superseded = append(superseded, candidate)
  489. delete(m.sessions, key)
  490. }
  491. return superseded
  492. }
  493. // evictToCapacity stops the least recently used sessions until there is room
  494. // for one more.
  495. func (m *HLSManager) evictToCapacity() {
  496. for {
  497. m.mu.Lock()
  498. if len(m.sessions) < hlsMaxSessions {
  499. m.mu.Unlock()
  500. return
  501. }
  502. var oldestKey string
  503. var oldest *HLSSession
  504. for key, session := range m.sessions {
  505. if oldest == nil || session.idleFor() > oldest.idleFor() {
  506. oldestKey, oldest = key, session
  507. }
  508. }
  509. if oldest == nil {
  510. m.mu.Unlock()
  511. return
  512. }
  513. delete(m.sessions, oldestKey)
  514. m.mu.Unlock()
  515. oldest.stop()
  516. logger.PrintAndLog("Transcoder", "Evicted HLS session "+oldest.ID+" to stay within the session limit", nil)
  517. }
  518. }
  519. func (m *HLSManager) segmentBaseURL(sessionID string) string {
  520. return m.segmentEndpoint + "?sid=" + sessionID + "&name="
  521. }
  522. // ReadPlaylist returns the session's playlist with the #EXT-X-MAP init segment
  523. // URI rewritten onto the segment endpoint.
  524. //
  525. // -hls_base_url only rewrites media segment URIs; ffmpeg always writes the
  526. // init segment as a bare filename. Left alone it would resolve relative to the
  527. // playlist URL (/media/hls?file=…) and 404, so the rewrite happens here rather
  528. // than leaving every client to work it out.
  529. func (m *HLSManager) ReadPlaylist(session *HLSSession) ([]byte, error) {
  530. content, err := os.ReadFile(session.PlaylistPath())
  531. if err != nil {
  532. return nil, err
  533. }
  534. return rewritePlaylistInitURI(content, m.segmentBaseURL(session.ID)+HLSInitSegmentName), nil
  535. }
  536. // rewritePlaylistInitURI replaces the URI inside an #EXT-X-MAP tag. Split out
  537. // from the file read so the substitution can be unit-tested directly.
  538. func rewritePlaylistInitURI(playlist []byte, initURL string) []byte {
  539. lines := strings.Split(string(playlist), "\n")
  540. for i, line := range lines {
  541. if !strings.HasPrefix(strings.TrimSpace(line), "#EXT-X-MAP:") {
  542. continue
  543. }
  544. start := strings.Index(line, `URI="`)
  545. if start < 0 {
  546. continue
  547. }
  548. start += len(`URI="`)
  549. end := strings.Index(line[start:], `"`)
  550. if end < 0 {
  551. continue
  552. }
  553. lines[i] = line[:start] + initURL + line[start+end:]
  554. }
  555. return []byte(strings.Join(lines, "\n"))
  556. }
  557. // WaitForPlaylist blocks until the session's playlist lists at least one
  558. // segment, so the player is never handed an empty playlist to choke on.
  559. func (s *HLSSession) WaitForPlaylist(timeout time.Duration) error {
  560. deadline := time.Now().Add(timeout)
  561. s.mu.Lock()
  562. exited := s.exited
  563. s.mu.Unlock()
  564. for {
  565. if playlistHasSegment(s.PlaylistPath()) {
  566. return nil
  567. }
  568. // Check for a dead transcode only after re-checking the playlist above,
  569. // so a process that exited right after writing its last segment still
  570. // counts as a success.
  571. select {
  572. case <-exited:
  573. if playlistHasSegment(s.PlaylistPath()) {
  574. return nil
  575. }
  576. return errors.New("transcode ended before producing any segment")
  577. default:
  578. }
  579. if time.Now().After(deadline) {
  580. return errors.New("timed out waiting for the first segment")
  581. }
  582. time.Sleep(200 * time.Millisecond)
  583. }
  584. }
  585. // playlistHasSegment reports whether the playlist on disk already references a
  586. // segment. ffmpeg writes the header before the first segment is complete, so
  587. // the file existing is not on its own enough.
  588. func playlistHasSegment(path string) bool {
  589. content, err := os.ReadFile(path)
  590. if err != nil {
  591. return false
  592. }
  593. return strings.Contains(string(content), hlsSegmentSuffix)
  594. }