Add Swoole alternative backend

This commit is contained in:
Jonathan Sykes
2026-10-14 00:43:38 +08:00
parent f465cc86d0
commit 1130fabc94
11 changed files with 756 additions and 0 deletions

109
alt/swoole/media-cache.php Normal file
View File

@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);
/** Server-owned, resumable-by-cache media jobs. Jobs are detached from HTTP request lifetime. */
final class MediaCache
{
private array $jobs = [];
public function __construct(private Store $db, private string $dir, private ?string $volumeMarker, private int $budget, private string $ytDlp, private string $ffmpeg, private string $ffprobe)
{
@mkdir($this->dir, 0775, true); @mkdir($this->dir . '/.tmp', 0775, true);
}
public static function validId(string $id): bool { return (bool)preg_match('/^[A-Za-z0-9_-]{6,64}$/', $id); }
public function row(string $id): ?array { return $this->db->one('SELECT * FROM media_cache WHERE video_id=?', [$id]); }
public function path(string $id, ?int $gen = null, string $ext = 'mp4'): ?string
{
$r = $this->row($id); if (!$r || $r['status'] !== 'ready') return null;
$gen ??= (int)$r['gen']; if ($gen < (int)$r['gen'] - 1 || $gen > (int)$r['gen']) return null;
$path = $this->dir . '/' . $id . '.' . $gen . '.' . $ext; return is_file($path) ? $path : null;
}
public function touch(string $id): void { $this->db->run('UPDATE media_cache SET last_access=?,hits=hits+1 WHERE video_id=?', [time(), $id]); }
public function status(string $id): array
{
$r = $this->row($id); if (!$r) return ['status'=>'missing','size'=>0,'gen'=>0];
return ['status'=>$r['status'],'size'=>(int)$r['size'],'gen'=>(int)$r['gen'],'height'=>(int)$r['height'],'optimized'=>(bool)$r['optimized'],'error'=>$r['error'] ?: null,'duration'=>(float)$r['duration'],'retryAt'=>(int)$r['retry_at']];
}
public function queue(string $id, int $priority): void
{
if (!self::validId($id) || isset($this->jobs[$id])) return;
$r = $this->row($id); if ($r && $r['status'] === 'ready' && $this->path($id)) { $this->touch($id); return; }
if ($r && $r['retry_at'] && (int)$r['retry_at'] > time()) return;
$this->jobs[$id] = true;
go(function() use ($id, $priority) { try { $this->downloadAndValidate($id, $priority); } catch (Throwable $e) { $this->fail($id, $e->getMessage()); } finally { unset($this->jobs[$id]); } });
}
private function downloadAndValidate(string $id, int $priority): void
{
$lock = fopen($this->dir . '/.tmp/media-cache.lock', 'c');
if (!$lock) throw new RuntimeException('cannot open media cache lock');
while (!flock($lock, LOCK_EX | LOCK_NB)) Swoole\Coroutine::sleep(.05);
try {
$existing = $this->row($id);
if ($existing && $existing['status'] === 'ready' && $this->path($id)) { $this->touch($id); return; }
if ($existing && (int)$existing['retry_at'] > time()) return;
if ($this->volumeMarker && !is_file($this->volumeMarker)) throw new RuntimeException('media volume marker is missing');
$this->db->run("INSERT INTO media_cache(video_id,status,priority,auto,created_at,updated_at) VALUES(?,'downloading',?,?,?,?) ON CONFLICT(video_id) DO UPDATE SET status='downloading',priority=MIN(media_cache.priority,excluded.priority),error=NULL,updated_at=excluded.updated_at", [$id,$priority,$priority === 1 ? 1 : 0,time(),time()]);
$target = $this->dir . '/.tmp/' . $id . '.' . bin2hex(random_bytes(5)) . '.mp4';
try {
$url = 'https://www.youtube.com/watch?v=' . rawurlencode($id);
$format = 'bestvideo[height<=720][vcodec^=avc1]+bestaudio[acodec^=mp4a]/best[height<=720][ext=mp4]';
$this->run([$this->ytDlp,$url,'--no-warnings','--no-playlist','-f',$format,'--merge-output-format','mp4','--limit-rate',getenv('DOWNLOAD_RATE') ?: '2M','-o',$target]);
if (!is_file($target)) throw new RuntimeException('yt-dlp did not produce an MP4');
$probe = $this->run([$this->ffprobe,'-v','error','-show_entries','format=duration:stream=codec_type,codec_name,height,pix_fmt','-of','json',$target]);
$facts = json_decode($probe['out'], true, 512, JSON_THROW_ON_ERROR); $streams = $facts['streams'] ?? [];
$video = null; $audio = false;
foreach ($streams as $s) { if (($s['codec_type'] ?? '') === 'video') $video = $s; if (($s['codec_type'] ?? '') === 'audio' && ($s['codec_name'] ?? '') === 'aac') $audio = true; }
if (!$video || ($video['codec_name'] ?? '') !== 'h264' || !$audio || !preg_match('/^yuvj?420p$/', $video['pix_fmt'] ?? 'yuv420p')) throw new RuntimeException('validation: expected 8-bit H.264 + AAC media');
$duration = (float)($facts['format']['duration'] ?? 0); if ($duration <= 0 || filesize($target) < 100 * 1024) throw new RuntimeException('validation: file duration or size is invalid');
$this->db->run("UPDATE media_cache SET status='validating',updated_at=? WHERE video_id=?", [time(),$id]);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-map','0','-c','copy','-f','null','-']);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-t','3','-f','null','-']);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-sseof','-5','-i',$target,'-f','null','-']);
$faststart = $target . '.faststart.mp4';
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-map','0:v:0','-map','0:a:0?','-c','copy','-movflags','+faststart','-y',$faststart]);
if (!is_file($faststart) || filesize($faststart) < 100 * 1024) throw new RuntimeException('faststart remux produced an invalid MP4');
@unlink($target); if (!rename($faststart,$target)) throw new RuntimeException('could not publish faststart MP4');
$idInfo = $this->run([$this->ytDlp,'-J','--no-warnings','--no-playlist',$url]); $info = json_decode($idInfo['out'], true) ?: [];
$sidecar = preg_replace('/\.mp4$/','.m4a',$target);
$this->run([$this->ffmpeg,'-v','error','-nostdin','-i',$target,'-vn','-c:a','copy','-movflags','+faststart','-y',$sidecar]);
if (!is_file($sidecar) || filesize($sidecar) < 1024) throw new RuntimeException('audio sidecar extraction failed');
$old = $this->row($id); $gen = (int)($old['gen'] ?? 0) + 1;
$final = $this->dir . '/' . $id . '.' . $gen . '.mp4'; $audio = $this->dir . '/' . $id . '.' . $gen . '.m4a';
if (!rename($target,$final)) throw new RuntimeException('could not publish media file');
if (!rename($sidecar,$audio)) { @unlink($final); throw new RuntimeException('could not publish audio sidecar'); }
foreach (['mp4','m4a'] as $ext) foreach (glob($this->dir.'/'.$id.'.*.'.$ext) ?: [] as $oldFile) {
if (preg_match('/\.(\d+)\.'.preg_quote($ext,'/').'$/',$oldFile,$oldMatch) && (int)$oldMatch[1] < $gen - 1) @unlink($oldFile);
}
$size = filesize($final) + (is_file($audio) ? filesize($audio) : 0); $sha = hash_file('sha256',$final);
$meta = json_encode(['title'=>$info['title'] ?? '(untitled)','channel'=>$info['channel'] ?? $info['uploader'] ?? '', 'channelId'=>$info['channel_id'] ?? $info['uploader_id'] ?? '', 'channelUrl'=>$info['channel_url'] ?? $info['uploader_url'] ?? '', 'duration'=>$duration], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
$this->db->run("UPDATE media_cache SET status='ready',gen=?,size=?,height=?,vcodec=?,acodec='aac',duration=?,optimized=1,meta=?,sha256=?,error=NULL,retry_at=0,attempts=0,updated_at=?,last_access=? WHERE video_id=?", [$gen,$size,(int)($video['height'] ?? 720),$video['codec_name'],$duration,$meta,$sha,time(),time(),$id]);
$this->evict();
} finally {
@unlink($target); @unlink($target . '.faststart.mp4'); @unlink(preg_replace('/\.mp4$/','.m4a',$target));
}
} finally { flock($lock, LOCK_UN); fclose($lock); }
}
private function run(array $command): array
{
$p = proc_open($command, [['pipe','r'],['pipe','w'],['pipe','w']], $pipes, null, null, ['bypass_shell'=>true]);
if (!is_resource($p)) throw new RuntimeException('could not start ' . $command[0]);
fclose($pipes[0]); stream_set_blocking($pipes[1], false); stream_set_blocking($pipes[2], false); $out=''; $err='';
$exitCode=null;
while (true) { $out .= stream_get_contents($pipes[1]); $err .= stream_get_contents($pipes[2]); $status=proc_get_status($p); if (!$status['running']) { $exitCode=(int)$status['exitcode']; break; } Swoole\Coroutine::sleep(.05); }
$out .= stream_get_contents($pipes[1]); $err .= stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]); $code=proc_close($p);
if ($code === -1 && $exitCode !== null) $code=$exitCode;
if ($code !== 0) throw new RuntimeException(trim(substr($err, -800)) ?: ($command[0] . ' failed'));
return ['out'=>$out,'err'=>$err];
}
private function fail(string $id, string $error): void
{
$r=$this->row($id); $tries=(int)($r['attempts'] ?? 0)+1; $delay=min(86400, 900 * (2 ** min(6,$tries-1)));
$this->db->run("INSERT INTO media_cache(video_id,status,attempts,error,retry_at,updated_at) VALUES(?,'failed',?,?,?,?) ON CONFLICT(video_id) DO UPDATE SET status='failed',attempts=excluded.attempts,error=excluded.error,retry_at=excluded.retry_at,updated_at=excluded.updated_at", [$id,$tries,substr($error,0,800),time()+$delay,time()]);
}
private function evict(): void
{
$max=$this->budget; $rows=$this->db->all("SELECT video_id,gen,last_access FROM media_cache WHERE status='ready' ORDER BY last_access ASC"); $sizes=[];
foreach($rows as $r){$id=$r['video_id'];$bytes=0;foreach(glob($this->dir.'/'.$id.'.*.mp4') ?: [] as $file)$bytes+=filesize($file) ?: 0;foreach(glob($this->dir.'/'.$id.'.*.m4a') ?: [] as $file)$bytes+=filesize($file) ?: 0;$sizes[$id]=$bytes;}
$total=array_sum($sizes);
foreach ($rows as $r) { if ($total <= $max) break; $id=$r['video_id']; foreach (glob($this->dir.'/'.$id.'.*.mp4') ?: [] as $file) @unlink($file); foreach (glob($this->dir.'/'.$id.'.*.m4a') ?: [] as $file) @unlink($file); $this->db->run("UPDATE media_cache SET status='missing',size=0,updated_at=? WHERE video_id=?", [time(),$id]); $total -= $sizes[$id] ?? 0; }
}
}