Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 54 additions & 34 deletions app/Http/Controllers/HlsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

use App\Models\MediaItem;
use App\Services\ContentGate;
use App\Services\Streaming\HlsPlaylist;
use App\Services\Streaming\HlsSegmenter;
use App\Services\Streaming\StreamPolicy;
use Illuminate\Http\JsonResponse;
Expand Down Expand Up @@ -73,6 +74,7 @@ public function playlist(Request $request, MediaItem $item): Response

$decision = $this->policy->decide($item, $request);
$from = max(0.0, (float) $request->float('from'));
$signed = $this->signsSegments($request);

$session = $this->segmenter->start(
$item,
Expand All @@ -82,41 +84,38 @@ public function playlist(Request $request, MediaItem $item): Response

abort_if($session === null, 503, 'Could not start the stream.');

$playlist = $this->waitForPlaylist($session);

abort_if($playlist === null, 504, 'The stream did not start in time.');

// Rewritten so each segment is fetched through this app rather than
// from a path on disk the player cannot reach.
// The whole timeline, written from the measured duration rather than
// read back from ffmpeg's partial output.
//
// Which route depends on who is asking. A browser carries its session
// cookie on every segment, so the web route is right for it. A native
// player does **not**: `AVURLAssetHTTPHeaderFieldsKey` applies to the
// playlist request and AVFoundation does not propagate those headers
// to the `.ts` fetches, so a segment arrives with no credentials at
// all, the auth middleware answers `302 -> /login`, AVPlayer follows
// it and decodes HTML as video. The result is a black picture with a
// running clock, because the playlist (which did carry the header)
// supplied the duration.
//
// So a token caller gets **signed** segment URLs instead: the
// signature travels in the URL, which is the one thing the player
// reliably keeps. The session id cannot do that job itself -- it is a
// deterministic hash of (item, height, start), so it is guessable and
// is not a credential.
$signed = $this->signsSegments($request);

$body = preg_replace_callback(
'/^(seg\d+\.ts)$/m',
fn (array $m): string => $signed
? URL::temporarySignedRoute(
'api.hls.segment',
now()->addHours(12),
['session' => $session, 'file' => $m[1]],
)
: route('media.hls.segment', ['session' => $session, 'file' => $m[1]]),
(string) file_get_contents($playlist),
);
// ffmpeg writes its playlist as it encodes, so reading it moments
// after the start lists only the few segments that exist -- and that
// is all the player believes the film to be. Seeking past them
// stalled, and even seeking backwards re-buffered, because each
// reload handed the player a different, longer playlist to reconcile.
$segmentUrl = fn (string $file): string => $signed
? URL::temporarySignedRoute(
'api.hls.segment',
now()->addHours(12),
['session' => $session, 'file' => $file],
)
: route('media.hls.segment', ['session' => $session, 'file' => $file]);

$body = app(HlsPlaylist::class)->forItem($item, $session, $segmentUrl);

if ($body === null) {
// No measured duration, so the timeline cannot be derived. Fall
// back to ffmpeg's own playlist, which is what shipped before --
// partial, but better than refusing to play.
$playlist = $this->waitForPlaylist($session);

abort_if($playlist === null, 504, 'The stream did not start in time.');

$body = preg_replace_callback(
'/^(seg\d+\.ts)$/m',
fn (array $m): string => $segmentUrl($m[1]),
(string) file_get_contents($playlist),
);
}

return response((string) $body, 200, [
'Content-Type' => 'application/vnd.apple.mpegurl',
Expand Down Expand Up @@ -146,6 +145,27 @@ public function segment(string $session, string $file): BinaryFileResponse
{
$path = $this->segmenter->fileIn($session, $file);

// Not there yet: the playlist promises the whole film, so a viewer
// can seek minutes past where the encode has reached. Producing the
// segment on request is what makes that seek fast instead of a wait
// for the encoder to arrive.
if ($path === null) {
$details = $this->segmenter->detailsFor($session);

if ($details !== null) {
$item = MediaItem::unresolved()->find($details['item']);

if ($item !== null && $this->gate->allows($item)) {
$path = $this->segmenter->produce(
$item,
$session,
$file,
$details['max_height'],
);
}
}
}

abort_if($path === null, 404);

return response()->file($path, [
Expand Down
87 changes: 87 additions & 0 deletions app/Services/Streaming/HlsPlaylist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 SoundChex

namespace App\Services\Streaming;

use App\Models\MediaItem;

/**
* The whole timeline, written before any of it has been encoded.
*
* ffmpeg writes its playlist as it goes, so a playlist read moments after the
* encode starts lists the handful of segments that exist — and that is all the
* player believes the film to be. Seeking past them stalls, and seeking
* *backwards* re-buffers too, because the player is being handed a different,
* longer playlist each time it asks and has to reconcile them.
*
* A VOD playlist is entirely derivable from two numbers: how long the film is,
* and how long a segment is. Both are known before a frame is encoded — the
* probe measured the duration — so the player can be given the real timeline
* at once and seek anywhere in it.
*
* Which leaves one problem, handled by `HlsSegmenter`: a segment the encoder
* has not reached yet. The playlist promising it is what makes the seek
* possible; producing it on request is what makes the seek fast.
*/
class HlsPlaylist
{
public function __construct(private HlsSegmenter $segmenter) {}

/**
* The complete playlist for an item, or null when its length is unknown.
*
* Null rather than a guess: a playlist whose duration is wrong is worse
* than none, because the player trusts it and seeks into nothing.
*/
public function forItem(MediaItem $item, string $session, callable $segmentUrl): ?string
{
$seconds = $this->durationSeconds($item);

if ($seconds === null || $seconds <= 0) {
return null;
}

$segmentLength = max(2, (int) config('transcode.hls.segment_seconds', 6));
$count = (int) ceil($seconds / $segmentLength);

$lines = [
'#EXTM3U',
'#EXT-X-VERSION:3',
'#EXT-X-TARGETDURATION:'.$segmentLength,
'#EXT-X-MEDIA-SEQUENCE:0',
'#EXT-X-PLAYLIST-TYPE:VOD',
];

for ($index = 0; $index < $count; $index++) {
// The last segment is short unless the film divides evenly, and
// saying so matters: a player that is told every segment is six
// seconds will seek past the end of a film whose final segment is
// two.
$remaining = $seconds - ($index * $segmentLength);
$length = min((float) $segmentLength, $remaining);

$lines[] = sprintf('#EXTINF:%.6F,', $length);
$lines[] = $segmentUrl(sprintf('seg%03d.ts', $index));
}

$lines[] = '#EXT-X-ENDLIST';

return implode("\n", $lines)."\n";
}

/**
* How long the item runs, in seconds.
*
* From the probe, which measured the file rather than repeating what a
* metadata source claimed — the number that matches what actually plays,
* and the only one safe to build a timeline from.
*/
private function durationSeconds(MediaItem $item): ?float
{
$ms = $item->probe?->duration_ms;

return $ms > 0 ? $ms / 1000 : null;
}
}
166 changes: 166 additions & 0 deletions app/Services/Streaming/HlsSegmenter.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
namespace App\Services\Streaming;

use App\Models\MediaItem;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;

Expand All @@ -31,6 +32,15 @@ class HlsSegmenter
/** Where a session's segments live, relative to the local disk. */
private const SESSION_ROOT = 'hls';

/**
* How many encodes one viewing may have running at once.
*
* One for the playback itself, and a few for seeks. Dragging a scrubber
* asks for many positions in a second, and an ffmpeg per position costs
* far more than the seeks save.
*/
private const ENCODES_PER_SESSION = 3;

/**
* Starts producing a stream, returning its session id.
*
Expand Down Expand Up @@ -69,6 +79,16 @@ public function start(MediaItem $item, int $maxHeight, float $from = 0.0): ?stri
Process::timeout(config('transcode.timeout_seconds', 21600))
->start($command);

// What this session is encoding, so a later segment request can
// produce a part the encoder has not reached. The id is a hash, so
// the item cannot be recovered from it -- and the segment route is
// given only the session.
Cache::put(
self::sessionKey($session),
['item' => $item->id, 'max_height' => $maxHeight],
now()->addHours(12),
);

Log::info('HLS: started a stream', [
'item' => $item->id,
'session' => $session,
Expand Down Expand Up @@ -139,6 +159,152 @@ public function directoryFor(string $session): string
}

/** A file inside a session, or null when the name is not one we wrote. */
/** Where a session's details are remembered. */
private static function sessionKey(string $session): string
{
return 'hls.session.'.$session;
}

/**
* What a session is encoding, or null if it is unknown or expired.
*
* @return array{item: int, max_height: int}|null
*/
public function detailsFor(string $session): ?array
{
$details = Cache::get(self::sessionKey($session));

return is_array($details) && isset($details['item'], $details['max_height'])
? ['item' => (int) $details['item'], 'max_height' => (int) $details['max_height']]
: null;
}

/**
* A segment the encoder has not reached, produced on demand.
*
* The playlist now promises the whole film up front, so a viewer can seek
* anywhere — including minutes past where the encode has got to. Without
* this that seek waits for the encoder to arrive, which for a seek near
* the end of a film is not a wait anybody will sit through.
*
* So a request for a segment that does not exist starts a **second**
* encode positioned at exactly that segment's offset. It writes into the
* same directory, so the first encode's output and this one's interleave
* into one coherent set, and whichever reaches a given segment first
* wins.
*
* Bounded by `ENCODES_PER_SESSION`, because a viewer dragging a scrubber
* can ask for a dozen positions in a second and each one is an ffmpeg.
*
* Returns the path once the segment exists, or null if it does not arrive
* in time — the player retries, and a null is a short stall rather than a
* failure.
*/
public function produce(MediaItem $item, string $session, string $name, int $maxHeight): ?string
{
if (preg_match('/^seg(\d{3,})\.ts$/', $name, $match) !== 1) {
return null;
}

$existing = $this->fileIn($session, $name);

if ($existing !== null) {
return $existing;
}

$source = $item->playbackPath();

if ($source === null) {
return null;
}

$directory = $this->directoryFor($session);
$segmentLength = max(2, (int) config('transcode.hls.segment_seconds', 6));
$index = (int) $match[1];
$offset = (float) ($index * $segmentLength);

if (! $this->claimEncode($session, $index)) {
// Already being produced, or too many running. Either way the
// answer is to wait rather than to start another.
return $this->waitFor($directory.'/'.$name);
}

// `-segment_start_number` so this encode writes the segment numbers
// it is actually producing rather than starting again at zero and
// overwriting the beginning of the film.
$command = $this->command($source, $directory, $maxHeight, $offset);
$command = $this->withStartNumber($command, $index);

Process::timeout(config('transcode.timeout_seconds', 21600))->start($command);

Log::info('HLS: encoding ahead for a seek', [
'item' => $item->id,
'session' => $session,
'segment' => $index,
'offset' => $offset,
]);

return $this->waitFor($directory.'/'.$name);
}

/**
* Whether this request should start an encode for a segment.
*
* One encode per segment, and a ceiling per session: dragging a scrubber
* asks for many positions in a moment, and starting an ffmpeg for each
* would cost more than the seek saves.
*/
private function claimEncode(string $session, int $index): bool
{
$key = 'hls.encoding.'.$session;
$running = (array) Cache::get($key, []);

// Expired claims are dropped: an encode that died must not block the
// segment it was producing for ever.
$running = array_filter($running, fn (int $at): bool => $at > time() - 120);

if (isset($running[$index])) {
return false;
}

if (count($running) >= self::ENCODES_PER_SESSION) {
return false;
}

$running[$index] = time();

Cache::put($key, $running, now()->addMinutes(10));

return true;
}

/** Adds the starting segment number to an encode command. */
private function withStartNumber(array $command, int $index): array
{
$output = array_pop($command);

return array_merge($command, ['-start_number', (string) $index], [$output]);
}

/**
* Waits briefly for a segment to appear.
*
* Short: the player is holding a request open, and a stall it can retry
* is better than one that times out the connection.
*/
private function waitFor(string $path): ?string
{
for ($attempt = 0; $attempt < 40; $attempt++) {
if (is_file($path) && filesize($path) > 0) {
return $path;
}

usleep(250_000);
}

return null;
}

public function fileIn(string $session, string $name): ?string
{
// Only the shapes ffmpeg produces. Anything else is someone walking
Expand Down
Loading
Loading