Add initial Restreamer implementation for YouTube-to-Jellyfin live restreaming
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from restreamer.constants import FFMPEG_READ_CHUNK_SIZE, FFMPEG_TERMINATE_TIMEOUT_SECONDS
|
||||
from restreamer.resolver import ResolvedStream
|
||||
|
||||
_RECONNECT_INPUT_ARGS = ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"]
|
||||
|
||||
|
||||
def build_ffmpeg_args(stream: ResolvedStream) -> list[str]:
|
||||
args = ["ffmpeg", "-loglevel", "warning", "-nostdin"]
|
||||
|
||||
header_lines = "".join(f"{key}: {value}\r\n" for key, value in stream.http_headers.items())
|
||||
|
||||
if header_lines:
|
||||
args += ["-headers", header_lines]
|
||||
args += [*_RECONNECT_INPUT_ARGS, "-i", stream.video_url]
|
||||
|
||||
if stream.audio_url:
|
||||
if header_lines:
|
||||
args += ["-headers", header_lines]
|
||||
args += [*_RECONNECT_INPUT_ARGS, "-i", stream.audio_url]
|
||||
args += ["-map", "0:v:0", "-map", "1:a:0"]
|
||||
|
||||
args += [
|
||||
"-c",
|
||||
"copy",
|
||||
"-f",
|
||||
"mpegts",
|
||||
"-mpegts_flags",
|
||||
"+resend_headers",
|
||||
"pipe:1",
|
||||
]
|
||||
return args
|
||||
|
||||
|
||||
class FfmpegStreamProcess:
|
||||
"""Wraps a running ffmpeg process that copies a resolved stream to stdout as MPEG-TS."""
|
||||
|
||||
def __init__(self, stream: ResolvedStream, channel_id: str) -> None:
|
||||
self._stream = stream
|
||||
self._channel_id = channel_id
|
||||
self._process: asyncio.subprocess.Process | None = None
|
||||
self._stderr_task: asyncio.Task[None] | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
args = build_ffmpeg_args(self._stream)
|
||||
logger.debug("Starting ffmpeg for channel '{}'", self._channel_id)
|
||||
self._process = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
||||
|
||||
async def iter_chunks(self) -> AsyncIterator[bytes]:
|
||||
if self._process is None or self._process.stdout is None:
|
||||
raise RuntimeError("ffmpeg process has not been started")
|
||||
|
||||
while True:
|
||||
chunk = await self._process.stdout.read(FFMPEG_READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
return
|
||||
yield chunk
|
||||
|
||||
async def wait(self) -> int:
|
||||
if self._process is None:
|
||||
raise RuntimeError("ffmpeg process has not been started")
|
||||
return await self._process.wait()
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._process is None or self._process.returncode is not None:
|
||||
return
|
||||
|
||||
self._process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(self._process.wait(), timeout=FFMPEG_TERMINATE_TIMEOUT_SECONDS)
|
||||
except TimeoutError:
|
||||
logger.warning("ffmpeg for channel '{}' did not exit in time, killing it", self._channel_id)
|
||||
self._process.kill()
|
||||
await self._process.wait()
|
||||
|
||||
if self._stderr_task is not None:
|
||||
await self._stderr_task
|
||||
|
||||
async def _drain_stderr(self) -> None:
|
||||
if self._process is None or self._process.stderr is None:
|
||||
return
|
||||
async for line in self._process.stderr:
|
||||
logger.debug("[ffmpeg:{}] {}", self._channel_id, line.decode(errors="replace").rstrip())
|
||||
Reference in New Issue
Block a user