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; } } }