Add initial Restreamer implementation for YouTube-to-Jellyfin live restreaming

This commit is contained in:
2026-09-27 11:19:19 +02:00
parent 70f2d191dc
commit a0b38c48e2
29 changed files with 2927 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
ENV_DEBUG=false
HTTP_HOST=0.0.0.0
HTTP_PORT=8000
CHANNELS_FILE=channels.yaml
# Base URL Jellyfin will use to reach this container, e.g. http://192.168.1.10:8000
PUBLIC_BASE_URL=
+4
View File
@@ -174,3 +174,7 @@ cython_debug/
# PyPI configuration file
.pypirc
# Restreamer runtime
logs/
channels.yaml
+105
View File
@@ -0,0 +1,105 @@
# AI Agents - Project Rules
**Document Version:** v8 (independent, incremented on structural changes)
Language-agnostic rules for AI assistants (Claude Code, Cursor, Copilot, etc.).
Everything language-specific — package manager, formatter, linter, test framework,
logging library, line length, project layout — lives in the matching design document:
| Project type | Design document |
|--------------------|------------------------------------|
| Python application | `DESIGN_DOCUMENT_PYTHON.md` |
| Python library | `DESIGN_DOCUMENT_PYTHON_MODULE.md` |
| Rust application | `DESIGN_DOCUMENT_RUST.md` |
| Rust library | `DESIGN_DOCUMENT_RUST_LIB.md` |
| Godot | `DESIGN_DOCUMENT_GODOT.md` |
The language is part of the file name, so a project that mixes languages carries one design document
per language and nothing collides.
Where this file and a design document disagree, **the design document wins**.
## First-time setup
- **On first read of this file, immediately read all other `.md` files in the project root** (e.g. `PROJECT.md`, `CHANGELOG.md`, `DESIGN_DOCUMENT*.md`) to get full project context before starting any task.
## Language
- **Always write all documentation in English**
## Dependency Management
- **Always add and remove dependencies through the package manager CLI** — **never edit the manifest** (`pyproject.toml`, `Cargo.toml`, …) by hand
- The exact commands are in the design document for the given language
## Project Structure
- Source code, tests and detailed documentation each have their own directory — the concrete layout is in the design document
- Detailed documentation belongs in `docs/`, never in the project root
- The project root holds only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT*.md`, `PROJECT.md`, `CHANGELOG.md`
- Entry points follow the language convention; a project may have several
- The dependency/build directory (`.venv/`, `target/`, …) is tool-managed — do not copy it, do not generate it by hand
## Code
- **Always use static typing** — annotate every parameter and return value
- Format and lint with the tools named in the design document
- **Nothing is committed without a clean formatter, linter and test run**
## Testing
- Use the test framework named in the design document — never a second framework alongside it
- Arrange-Act-Assert pattern
- Test naming: `test_<action>_<context>`
## Logging
- Use the logging library named in the design document — **never `print()` (or its language equivalent) for debugging**
- Never log secrets, passwords, tokens, or API keys
## Environment and Secrets
- Applications store secrets in `.env` and load them at runtime — **never commit `.env`**
- Debug mode is driven by an `ENV_DEBUG=true/false` flag
- Libraries do not read `.env` — configuration is passed in by the caller
## Git
- `.gitignore` must cover at least: the dependency/build directory, tool caches, and `.env`
- **Commit the lock file for applications, do not commit it for libraries**
- **Every core document is committed**, including the shared ones (`CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT*.md`). A checkout then carries the rules that applied to that code, and a fresh clone works standalone.
- **Never edit a shared document inside a project** — it is a copy. Changes belong in the documentation repository and are copied outward; project-specific deviations go in `PROJECT.md`.
- Synchronising the copies is its own commit (`docs: sync guidelines to AGENTS v8 / DESIGN_DOCUMENT_PYTHON v11`) and is **not** recorded in the project `CHANGELOG.md` — it is not a change to the product
### Commit messages
- **Never sign commits with AI authorship** — no `Co-Authored-By: Claude` (or any other assistant), no "Generated with …" line, no tool name or emoji footer. The same applies to pull request descriptions and issue comments.
- The commit author is the human running the tool; the message describes the change, nothing else
- Single-line verbal style — "Add X", "Fix Y", "Refactor Z" — never a filename-style message
- Written in English
## Versioning
- Follow semantic versioning (MAJOR.MINOR.PATCH)
- **Always ask user before bumping version** — never increase version automatically
- **Keep `CHANGELOG.md` updated** — document all significant changes as they are made
- Update `CHANGELOG.md` **before** the version bump
- The version source of truth is the project manifest (`pyproject.toml`, `Cargo.toml`, `project.godot`)
## Task Management
Tasks are **single-line comments in the code**, written with Todo Tree tags:
| Tag | Meaning |
|----------|--------------------------------------------------|
| `TODO:` | work still to be done |
| `FIXME:` | something broken that must be repaired |
| `BUG:` | a known defect, not fixed yet |
| `HACK:` | temporary workaround, needs rewriting |
| `NOTE:` | important context for whoever reads this next |
- **No checkboxes and no numbered task lists in documentation** — the code is the task list
- `PROJECT.md` carries only cross-cutting tasks that have no single place in the code
- If a tag already exists at a location in code, do not repeat it in `PROJECT.md`
- **Update documentation** — when completing changes, update the relevant sections of `PROJECT.md` and `CHANGELOG.md`
+16
View File
@@ -0,0 +1,16 @@
# Changelog
All notable changes to this project are documented in this file.
## [0.1.0] - Unreleased
### Added
- Initial implementation: yt-dlp based resolver (merging separate video/audio renditions when YouTube serves a live
stream split that way), ffmpeg restreaming, per-channel supervisor worker with proactive URL-expiry refresh and
reactive restart-with-backoff, broadcast fan-out to multiple HTTP clients, aiohttp server exposing an M3U playlist
and per-channel MPEG-TS streams for Jellyfin Live TV.
- Docker Compose deployment (`Dockerfile`, `docker-compose.yml`) and configuration templates
(`channels.example.yaml`, `.env.example`).
- Unit test suite covering config parsing, URL expiry parsing, ffmpeg argument building, playlist generation, and
broadcast fan-out.
+14
View File
@@ -0,0 +1,14 @@
# CLAUDE.md
**Document Version:** v3 (independent, incremented on structural changes)
## First-time setup
**At the start of every new session, read all of the following files before doing anything else:**
- `CLAUDE.md` (this file)
- `README.md`
- `AGENTS.md`
- `PROJECT.md`
- `CHANGELOG.md`
- `DESIGN_DOCUMENT*.md` — one per language used in the project (`DESIGN_DOCUMENT_PYTHON.md`, `DESIGN_DOCUMENT_PYTHON_MODULE.md`, `DESIGN_DOCUMENT_RUST.md`, `DESIGN_DOCUMENT_RUST_LIB.md`, `DESIGN_DOCUMENT_GODOT.md`)
+232
View File
@@ -0,0 +1,232 @@
# Python Development Guidelines
**Document Version:** v11
> **Note on Versioning:**
> - This document version is independent — reused across projects
> - **Project version** source of truth: `pyproject.toml`
> - Version propagates: `pyproject.toml` → `constants.py` → code
> - `CHANGELOG.md` uses project version from `pyproject.toml`
## Related Documents
- **README.md** — Project overview, tool descriptions, build instructions
- **AGENTS.md** — Rules for AI assistants
- **PROJECT.md** — Project goals and current state
- **CHANGELOG.md** — Version history
### Documentation Organization
All detailed documentation of features and systems belongs in the `docs/` folder, not in the project root.
The root directory contains only the core documents: `README.md`, `CLAUDE.md`, `AGENTS.md`, `DESIGN_DOCUMENT_PYTHON.md`, `PROJECT.md`, `CHANGELOG.md`.
---
## 1. Code Style
- **PEP8** with 120-character lines (Ruff)
- **4 spaces** indentation
- **snake_case** functions/variables, **PascalCase** classes, **SCREAMING_SNAKE_CASE** constants
- **Type hints** required on all functions
- **Import order**: stdlib → third-party → local
---
## 2. SOLID Principles
- **SRP** — One class = one responsibility
- **OCP** — Open for extension, closed for modification
- **LSP** — Subclasses substitutable for parents
- **ISP** — Small interfaces over large ones
- **DIP** — Depend on abstractions
---
## 3. Dependency Injection
Pass dependencies via constructor. Never instantiate dependencies inside a class.
---
## 4. Protocols Over Inheritance
Prefer `typing.Protocol` and composition over class inheritance.
---
## 5. Data Classes
Use `@dataclass` for internal data structures, `pydantic.BaseModel` for data that requires validation.
---
## 6. Logging and Console Output
### Logging — loguru
Use **loguru** for all internal logging. Never log secrets, passwords, tokens, or API keys.
#### Log sinks
| Sink | Level | Format |
|------|-------|--------|
| File `logs/{AppName}_{time}.log` | `DEBUG` | full (timestamp + level + message) |
| stdout | `INFO` | full |
File sink retains **max 10 log files** (`retention=10`). No rotation by size — each run creates a new file via `{time}` in the filename.
The `DEBUG` sink is only active when `constants.DEFAULT_DEBUG` is `True` (controlled by `ENV_DEBUG=true` in `.env`).
Additional sinks (e.g. GUI log panels) may be added per project as needed.
#### Log levels
| Level | When to use |
|-------|-------------|
| `DEBUG` | Per-item detail: individual file reads/writes, cache hits, per-row operations |
| `INFO` | User-visible milestones: file loaded, process started/finished, file saved |
| `WARNING` | Recoverable issues: fallback used, unexpected but non-fatal state |
| `ERROR` | Failures the user must know about: missing file, failed save, missing config — operation cannot continue |
### Console output — print()
`print()` is **allowed** for direct user-facing console communication: input prompts, progress lines, and result summaries. This applies to console tools that interact with the user through stdin/stdout.
`print()` is **not** a substitute for loguru. It must not be used for debugging or internal event tracking.
---
## 7. Environment and Secrets
- Secrets in `.env` file; controlled by `ENV_DEBUG=true/false`
- Load via `python-dotenv` and `os.getenv()`
- Never commit `.env`
---
## 8. Error Handling
Define specific exception types. Use a fail-fast approach — surface errors early rather than silently continuing.
---
## 9. Testing
- **pytest** only — no `unittest`, no `TestCase` classes, no `self.assert*`
- Arrange-Act-Assert pattern
- Test naming: `test_<action>_<context>`
---
## 10. Tooling
| Tool | Purpose |
|------|---------|
| **Ruff** | Formatting and linting |
| **mypy** | Static type checking |
| **pytest** | Testing |
Run before every commit:
```bash
poetry run ruff check
poetry run mypy
poetry run pytest
```
---
## 11. Poetry
```bash
poetry install # Install all dependencies
poetry add <pkg> # Add runtime dependency
poetry add --group dev <pkg> # Add dev dependency
poetry remove <pkg> # Remove dependency
poetry run <cmd> # Run command in virtualenv
```
Never edit `pyproject.toml` directly to add or remove dependencies.
### poetry.lock
`poetry.lock` **is committed** for applications — it pins the exact dependency graph and keeps builds reproducible.
---
## 12. Project Structure
```
project/
├── entry_point.py # Entry point(s) — named by purpose or tool name
├── src/ # All application modules
├── tests/ # Tests
├── docs/ # Detailed documentation
├── .venv/ # Virtual environment (managed by Poetry)
└── pyproject.toml # Project config and dependencies
```
A project may have multiple entry points (e.g. `cli.py`, `gui.py`, or per-tool scripts).
---
## 13. Distribution and Deployment
When a project is distributed as a standalone executable (no Python required on target machine):
- Use **PyInstaller** to compile each entry point into a single `.exe`
- Each tool has its own `.spec` file in the project root
- **Console tools use `console=True`** in the `.spec` — they rely on `input()` and `print()` for user interaction. GUI applications turn the console off with `ENV_BUILD_CONSOLE=false`; that flag is independent of `ENV_DEBUG`
- Compiled executables are stored in `dist/` and **committed to the repository** — the repository serves as the distribution channel for internal teams
- `.gitignore` must **not** exclude `dist/` in projects that use this deployment model
### prebuild.py
`prebuild.py` runs before PyInstaller. It verifies that the active interpreter is the project `.venv`, prints the resolved version, and rewrites the `console=` line in the `.spec` to match `.env`.
| Variable | Default | Effect |
|----------|---------|--------|
| `ENV_BUILD_CONSOLE` | `true` | Value written into `console=` in the `.spec` |
| `ENV_BUILD_SPEC` | `<project folder>.spec` | Which `.spec` file to update |
`prebuild.py` touches one `.spec` per run — with several entry points, set `ENV_BUILD_SPEC` for each build.
Build commands:
```bash
poetry run python prebuild.py
poetry run pyinstaller ToolName.spec
```
> This section applies only to projects that produce standalone executables. Skip for libraries or web services.
---
## 14. Versioning
- Follow **semantic versioning**: `MAJOR.MINOR.PATCH`
- Version is defined in `pyproject.toml` under `[project]`
- Always ask before bumping the version — never increment automatically
- Update `CHANGELOG.md` before bumping the version
---
## 15. Documentation and Task Management
- Keep `PROJECT.md` and `CHANGELOG.md` up to date when making changes
- Document architectural changes in this file or in `docs/`
### Task notation
Tasks are written as single-line comments directly in code, using the **Todo Tree** tags defined in `AGENTS.md` (`TODO`, `FIXME`, `BUG`, `HACK`, `NOTE`). `PROJECT.md` carries only cross-cutting tasks that have no single place in the code.
```python
# TODO: extract this into a separate loader
# FIXME: crashes on an empty file
# BUG: rounding is off by one cent on negative amounts
# HACK: temporary workaround until the API adds paging
# NOTE: order matters here, the parser is stateful
```
No other task format is used — **no checkboxes, no numbered lists in documentation**.
If a tag already exists at a specific location in code, do not repeat it in `PROJECT.md`.
+22
View File
@@ -0,0 +1,22 @@
FROM python:3.12-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir poetry==2.4.1
ENV POETRY_VIRTUALENVS_CREATE=false
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry install --only main --no-root --no-interaction
COPY src/ src/
COPY serve.py ./
ENV PYTHONPATH=/app/src
EXPOSE 8000
CMD ["python", "serve.py"]
+28
View File
@@ -0,0 +1,28 @@
# PROJECT.md
## Goal
A Docker container bundling Python, ffmpeg and yt-dlp that takes YouTube livestream URLs on one side and serves them
as continuous, always-on streams on the other, consumable by Jellyfin Live TV via an M3U tuner. The service handles
its own stream-session renewal so a channel does not die when YouTube's resolved URL expires after a few hours.
## Current state
Initial implementation in place:
- `src/restreamer/` — resolver (yt-dlp), ffmpeg process wrapper, per-channel supervisor worker, broadcast fan-out,
aiohttp server (`/playlist.m3u`, `/stream/{channel_id}`, `/healthz`), config loading.
- `serve.py` — entry point.
- `Dockerfile` / `docker-compose.yml` — containerized deployment.
- `channels.example.yaml`, `.env.example` — configuration templates.
- Unit tests for the pure/testable pieces (config parsing, URL expiry parsing, ffmpeg argument building, playlist
generation, broadcast fan-out).
Not yet done / not yet verified against a real YouTube livestream and a real Jellyfin instance — see `TODO`/`NOTE`
comments in code for anything more granular.
## Open questions
- Whether channels also need XMLTV EPG data, or the bare M3U + `tvg-id` is enough for how Jellyfin is being used here.
- Whether any of the YouTube URLs to restream require authentication (cookies) — `YtDlpResolver` accepts extra
`ydl_opts` for this but no cookie handling is wired up yet.
+39
View File
@@ -1,2 +1,41 @@
# Restreamer
Restreams YouTube livestreams as continuous local streams that Jellyfin (or any player) can watch as Live TV.
## How it works
1. **yt-dlp** resolves a YouTube livestream URL to a direct, playable stream URL.
2. **ffmpeg** copies that stream (no re-encoding) into a continuous MPEG-TS byte stream.
3. A small **async HTTP server** fans that byte stream out to any number of connected clients, and publishes an M3U playlist listing every configured channel.
4. A **worker per channel** keeps the stream alive indefinitely: it proactively re-resolves the source before the underlying URL expires, and reactively restarts ffmpeg (with backoff) if it ever exits unexpectedly. This is the "session circulation" that keeps a channel from dying every few hours.
## Running
1. Copy `channels.example.yaml` to `channels.yaml` and list your channels.
2. Copy `.env.example` to `.env` and adjust as needed (in particular `PUBLIC_BASE_URL`, the address Jellyfin will use to reach this service).
3. Run with Docker Compose:
```bash
docker compose up --build
```
4. In Jellyfin, add a **Live TV M3U Tuner** pointing at `http://<host>:8000/playlist.m3u`.
### Local development
```bash
poetry install
poetry run python serve.py
```
Requires `ffmpeg` on `PATH`.
## Development
```bash
poetry run ruff check
poetry run mypy
poetry run pytest
```
See `PROJECT.md` for current status and `DESIGN_DOCUMENT_PYTHON.md` / `AGENTS.md` for project conventions.
+9
View File
@@ -0,0 +1,9 @@
channels:
- id: example-news
name: "Example News Live"
url: "https://www.youtube.com/watch?v=REPLACE_WITH_VIDEO_ID"
- id: example-music
name: "Example Music Live"
url: "https://www.youtube.com/watch?v=REPLACE_WITH_VIDEO_ID"
refresh_interval_seconds: 14400
+14
View File
@@ -0,0 +1,14 @@
services:
restreamer:
build: .
ports:
- "8000:8000"
environment:
- ENV_DEBUG=false
- HTTP_HOST=0.0.0.0
- HTTP_PORT=8000
- CHANNELS_FILE=/app/channels.yaml
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-}
volumes:
- ./channels.yaml:/app/channels.yaml:ro
restart: unless-stopped
Generated
+1683
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
[project]
name = "restreamer"
version = "0.1.0"
description = "Restreams YouTube livestreams as continuous local streams consumable by Jellyfin Live TV"
authors = [
{name = "Jan Doubravsky",email = "jan.doubravsky@gmail.com"}
]
license = "MIT"
readme = "README.md"
requires-python = ">=3.12,<4.0"
dependencies = [
"yt-dlp (>=2026.8.19,<2027.0.0)",
"aiohttp (>=3.14.3,<4.0.0)",
"pydantic (>=2)",
"pydantic-settings (>=2.15.0,<3.0.0)",
"loguru (>=0.7.3,<0.8.0)",
"python-dotenv (>=1.2.3,<2.0.0)",
"pyyaml (>=6.0.3,<7.0.0)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"ruff (>=0.16.9,<0.17.0)",
"mypy (>=2.3.1,<3.0.0)",
"pytest (>=9.1.1,<10.0.0)",
"pytest-asyncio (>=1.4.0,<2.0.0)",
"types-pyyaml (>=6.0.12.20260906,<7.0.0.0)",
"types-yt-dlp (>=2026.8.19.20260912,<2027.0.0.0)"
]
[tool.poetry]
packages = [{include = "restreamer", from = "src"}]
[tool.ruff]
line-length = 120
[tool.mypy]
python_version = "3.12"
strict = true
mypy_path = "src"
[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
+24
View File
@@ -0,0 +1,24 @@
import asyncio
from dotenv import load_dotenv
from restreamer.config import Settings, load_channels
from restreamer.logging_setup import configure_logging
from restreamer.manager import RestreamerManager
from restreamer.resolver import YtDlpResolver
def main() -> None:
load_dotenv()
settings = Settings()
configure_logging(settings.env_debug)
channels = load_channels(settings.channels_file)
resolver = YtDlpResolver()
manager = RestreamerManager(settings, channels, resolver)
asyncio.run(manager.run())
if __name__ == "__main__":
main()
View File
+42
View File
@@ -0,0 +1,42 @@
import asyncio
from contextlib import suppress
from loguru import logger
from restreamer.constants import CLIENT_QUEUE_MAXSIZE
class ChannelBroadcaster:
"""Fans a single upstream byte stream out to any number of connected clients."""
def __init__(self, channel_id: str) -> None:
self._channel_id = channel_id
self._subscribers: set[asyncio.Queue[bytes | None]] = set()
def subscribe(self) -> asyncio.Queue[bytes | None]:
queue: asyncio.Queue[bytes | None] = asyncio.Queue(maxsize=CLIENT_QUEUE_MAXSIZE)
self._subscribers.add(queue)
logger.debug("Channel '{}' gained a subscriber ({} total)", self._channel_id, len(self._subscribers))
return queue
@property
def subscriber_count(self) -> int:
return len(self._subscribers)
def unsubscribe(self, queue: asyncio.Queue[bytes | None]) -> None:
self._subscribers.discard(queue)
logger.debug("Channel '{}' lost a subscriber ({} total)", self._channel_id, len(self._subscribers))
def publish(self, chunk: bytes) -> None:
for queue in list(self._subscribers):
try:
queue.put_nowait(chunk)
except asyncio.QueueFull:
logger.warning("Dropping slow subscriber on channel '{}'", self._channel_id)
self._subscribers.discard(queue)
def close(self) -> None:
for queue in list(self._subscribers):
with suppress(asyncio.QueueFull):
queue.put_nowait(None)
self._subscribers.clear()
+72
View File
@@ -0,0 +1,72 @@
import asyncio
import time
from datetime import UTC, datetime
from loguru import logger
from restreamer.broadcaster import ChannelBroadcaster
from restreamer.config import ChannelConfig
from restreamer.constants import (
BACKOFF_RESET_AFTER_HEALTHY_SECONDS,
MAX_RESTART_BACKOFF_SECONDS,
MIN_RESTART_BACKOFF_SECONDS,
)
from restreamer.ffmpeg_runner import FfmpegStreamProcess
from restreamer.resolver import StreamResolver
class ChannelWorker:
"""Keeps one channel's stream alive: resolve -> run ffmpeg -> refresh/restart forever."""
def __init__(self, channel: ChannelConfig, resolver: StreamResolver, broadcaster: ChannelBroadcaster) -> None:
self._channel = channel
self._resolver = resolver
self._broadcaster = broadcaster
self._stopped = asyncio.Event()
async def run(self) -> None:
backoff = MIN_RESTART_BACKOFF_SECONDS
while not self._stopped.is_set():
started_at = time.monotonic()
try:
await self._run_once()
backoff = MIN_RESTART_BACKOFF_SECONDS
except Exception: # noqa: BLE001 -- one channel's failure must never take down the others
logger.exception("Channel '{}' failed, will retry", self._channel.id)
if self._stopped.is_set():
break
if time.monotonic() - started_at >= BACKOFF_RESET_AFTER_HEALTHY_SECONDS:
backoff = MIN_RESTART_BACKOFF_SECONDS
logger.info("Channel '{}' restarting in {:.0f}s", self._channel.id, backoff)
await self._sleep_unless_stopped(backoff)
backoff = min(backoff * 2, MAX_RESTART_BACKOFF_SECONDS)
def stop(self) -> None:
self._stopped.set()
async def _run_once(self) -> None:
resolved = await self._resolver.resolve(str(self._channel.url))
refresh_deadline = resolved.expires_at
process = FfmpegStreamProcess(resolved, self._channel.id)
await process.start()
logger.info("Channel '{}' streaming (refresh due {})", self._channel.id, refresh_deadline)
try:
async for chunk in process.iter_chunks():
self._broadcaster.publish(chunk)
if refresh_deadline is not None and datetime.now(tz=UTC) >= refresh_deadline:
logger.info("Channel '{}' proactively refreshing before URL expiry", self._channel.id)
break
finally:
await process.stop()
async def _sleep_unless_stopped(self, seconds: float) -> None:
try:
await asyncio.wait_for(self._stopped.wait(), timeout=seconds)
except TimeoutError:
pass
+43
View File
@@ -0,0 +1,43 @@
from pathlib import Path
import yaml
from pydantic import BaseModel, Field, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
from restreamer import constants
class ChannelConfig(BaseModel):
id: str
name: str
url: HttpUrl
refresh_interval_seconds: int = constants.DEFAULT_REFRESH_INTERVAL_SECONDS
class ChannelsFile(BaseModel):
channels: list[ChannelConfig] = Field(default_factory=list)
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="", extra="ignore")
env_debug: bool = Field(default=False, alias="ENV_DEBUG")
http_host: str = Field(default=constants.DEFAULT_HTTP_HOST, alias="HTTP_HOST")
http_port: int = Field(default=constants.DEFAULT_HTTP_PORT, alias="HTTP_PORT")
channels_file: Path = Field(default=Path(constants.DEFAULT_CHANNELS_FILE), alias="CHANNELS_FILE")
public_base_url: str | None = Field(default=None, alias="PUBLIC_BASE_URL")
def load_channels(path: Path) -> list[ChannelConfig]:
if not path.is_file():
raise FileNotFoundError(f"Channels file not found: {path}")
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
parsed = ChannelsFile.model_validate(raw)
ids = [channel.id for channel in parsed.channels]
duplicates = {channel_id for channel_id in ids if ids.count(channel_id) > 1}
if duplicates:
raise ValueError(f"Duplicate channel id(s) in {path}: {sorted(duplicates)}")
return parsed.channels
+28
View File
@@ -0,0 +1,28 @@
import os
APP_NAME = "Restreamer"
DEFAULT_DEBUG = os.getenv("ENV_DEBUG", "false").lower() == "true"
DEFAULT_HTTP_HOST = "0.0.0.0"
DEFAULT_HTTP_PORT = 8000
DEFAULT_CHANNELS_FILE = "channels.yaml"
# Proactive refresh ceiling used when a resolved stream carries no expiry hint.
DEFAULT_REFRESH_INTERVAL_SECONDS = 4 * 60 * 60
# Restart backoff for a channel worker after a failed resolve/ffmpeg run.
MIN_RESTART_BACKOFF_SECONDS = 5.0
MAX_RESTART_BACKOFF_SECONDS = 120.0
BACKOFF_RESET_AFTER_HEALTHY_SECONDS = 60.0
# Grace period given to ffmpeg to exit on SIGTERM before SIGKILL.
FFMPEG_TERMINATE_TIMEOUT_SECONDS = 5.0
FFMPEG_READ_CHUNK_SIZE = 188 * 64 # multiple of the MPEG-TS packet size
MPEGTS_CONTENT_TYPE = "video/mp2t"
# Bounded per-client output queue; a slow client is dropped rather than
# allowed to stall the broadcast for everyone else.
CLIENT_QUEUE_MAXSIZE = 128
+92
View File
@@ -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())
+64
View File
@@ -0,0 +1,64 @@
from aiohttp import web
from loguru import logger
from restreamer.broadcaster import ChannelBroadcaster
from restreamer.config import ChannelConfig
from restreamer.constants import MPEGTS_CONTENT_TYPE
def build_playlist(channels: list[ChannelConfig], base_url: str) -> str:
base_url = base_url.rstrip("/")
lines = ["#EXTM3U"]
for channel in channels:
lines.append(f'#EXTINF:-1 tvg-id="{channel.id}",{channel.name}')
lines.append(f"{base_url}/stream/{channel.id}")
return "\n".join(lines) + "\n"
def create_app(channels: list[ChannelConfig], broadcasters: dict[str, ChannelBroadcaster], base_url: str) -> web.Application:
app = web.Application()
app["channels"] = channels
app["broadcasters"] = broadcasters
app["base_url"] = base_url
app.router.add_get("/playlist.m3u", _handle_playlist)
app.router.add_get("/stream/{channel_id}", _handle_stream)
app.router.add_get("/healthz", _handle_health)
return app
async def _handle_playlist(request: web.Request) -> web.Response:
channels: list[ChannelConfig] = request.app["channels"]
base_url: str = request.app["base_url"]
body = build_playlist(channels, base_url)
return web.Response(text=body, content_type="audio/x-mpegurl")
async def _handle_stream(request: web.Request) -> web.StreamResponse:
channel_id = request.match_info["channel_id"]
broadcasters: dict[str, ChannelBroadcaster] = request.app["broadcasters"]
broadcaster = broadcasters.get(channel_id)
if broadcaster is None:
raise web.HTTPNotFound(text=f"Unknown channel '{channel_id}'")
response = web.StreamResponse(status=200, headers={"Content-Type": MPEGTS_CONTENT_TYPE})
await response.prepare(request)
queue = broadcaster.subscribe()
try:
while True:
chunk = await queue.get()
if chunk is None:
break
await response.write(chunk)
except ConnectionResetError:
logger.debug("Client disconnected from channel '{}'", channel_id)
finally:
broadcaster.unsubscribe(queue)
return response
async def _handle_health(request: web.Request) -> web.Response:
channels: list[ChannelConfig] = request.app["channels"]
return web.json_response({"status": "ok", "channels": [c.id for c in channels]})
+23
View File
@@ -0,0 +1,23 @@
import sys
from loguru import logger
from restreamer.constants import APP_NAME
def configure_logging(debug: bool) -> None:
logger.remove()
logger.add(
sys.stdout,
level="INFO",
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
)
if debug:
logger.add(
f"logs/{APP_NAME}_{{time}}.log",
level="DEBUG",
retention=10,
format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
)
+48
View File
@@ -0,0 +1,48 @@
import asyncio
import signal
from aiohttp import web
from loguru import logger
from restreamer.broadcaster import ChannelBroadcaster
from restreamer.channel_worker import ChannelWorker
from restreamer.config import ChannelConfig, Settings
from restreamer.http_server import create_app
from restreamer.resolver import StreamResolver
class RestreamerManager:
def __init__(self, settings: Settings, channels: list[ChannelConfig], resolver: StreamResolver) -> None:
self._settings = settings
self._channels = channels
self._resolver = resolver
self._broadcasters = {channel.id: ChannelBroadcaster(channel.id) for channel in channels}
self._workers = [
ChannelWorker(channel, resolver, self._broadcasters[channel.id]) for channel in channels
]
async def run(self) -> None:
base_url = self._settings.public_base_url or f"http://{self._settings.http_host}:{self._settings.http_port}"
app = create_app(self._channels, self._broadcasters, base_url)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, self._settings.http_host, self._settings.http_port)
await site.start()
logger.info("HTTP server listening on {}:{}", self._settings.http_host, self._settings.http_port)
logger.info("Playlist available at {}/playlist.m3u", base_url)
worker_tasks = [asyncio.create_task(worker.run()) for worker in self._workers]
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, stop_event.set)
await stop_event.wait()
logger.info("Shutting down")
for worker in self._workers:
worker.stop()
await asyncio.gather(*worker_tasks, return_exceptions=True)
await runner.cleanup()
+98
View File
@@ -0,0 +1,98 @@
import asyncio
from dataclasses import dataclass, field
from datetime import UTC, datetime, timedelta
from typing import Any, Protocol
from urllib.parse import parse_qs, urlparse
import yt_dlp
from loguru import logger
from restreamer.constants import DEFAULT_REFRESH_INTERVAL_SECONDS
class ResolveError(RuntimeError):
"""Raised when a source URL cannot be resolved to a playable stream."""
@dataclass(frozen=True)
class ResolvedStream:
video_url: str
audio_url: str | None = None
http_headers: dict[str, str] = field(default_factory=dict)
expires_at: datetime | None = None
class StreamResolver(Protocol):
async def resolve(self, source_url: str) -> ResolvedStream: ...
def parse_expiry_from_url(url: str) -> datetime | None:
"""Extract the `expire` query parameter googlevideo URLs carry, if present."""
query = parse_qs(urlparse(url).query)
values = query.get("expire")
if not values:
return None
try:
timestamp = int(values[0])
except ValueError:
return None
return datetime.fromtimestamp(timestamp, tz=UTC)
class YtDlpResolver:
"""Resolves a YouTube (live) URL to a direct, playable stream via yt-dlp."""
def __init__(self, ydl_opts: dict[str, Any] | None = None) -> None:
self._ydl_opts: dict[str, Any] = {
"quiet": True,
"no_warnings": True,
"noplaylist": True,
# Live YouTube streams are frequently served as separate video-only and
# audio-only HLS renditions rather than one muxed format.
"format": "bestvideo*+bestaudio/best",
}
if ydl_opts:
self._ydl_opts.update(ydl_opts)
async def resolve(self, source_url: str) -> ResolvedStream:
info = await asyncio.to_thread(self._extract_info, source_url)
return self._to_resolved_stream(info)
def _extract_info(self, source_url: str) -> dict[str, Any]:
# options are intentionally a plain dynamic dict, not the stub's strict TypedDict
with yt_dlp.YoutubeDL(self._ydl_opts) as ydl: # type: ignore[arg-type]
info = ydl.extract_info(source_url, download=False)
if not info:
raise ResolveError(f"yt-dlp returned no information for {source_url}")
return dict(info)
@staticmethod
def _to_resolved_stream(info: dict[str, Any]) -> ResolvedStream:
video_url, audio_url = YtDlpResolver._select_urls(info)
headers = dict(info.get("http_headers") or {})
expires_at = parse_expiry_from_url(video_url)
if expires_at is None:
expires_at = datetime.now(tz=UTC) + timedelta(seconds=DEFAULT_REFRESH_INTERVAL_SECONDS)
logger.debug("No expiry hint in resolved URL, falling back to default refresh interval")
return ResolvedStream(video_url=video_url, audio_url=audio_url, http_headers=headers, expires_at=expires_at)
@staticmethod
def _select_urls(info: dict[str, Any]) -> tuple[str, str | None]:
requested_formats = info.get("requested_formats")
if requested_formats:
# Audio-only renditions are not consistently given an explicit acodec by
# yt-dlp, but a video rendition always has a real vcodec -- classify by that.
video_fmt = next((f for f in requested_formats if f.get("vcodec") not in (None, "none")), None)
audio_fmt = next((f for f in requested_formats if f is not video_fmt), None)
if video_fmt is None or audio_fmt is None:
raise ResolveError("yt-dlp did not return both a video and an audio format")
return video_fmt["url"], audio_fmt["url"]
video_url = info.get("url")
if not video_url:
raise ResolveError("yt-dlp did not return a playable direct URL")
return video_url, None
+35
View File
@@ -0,0 +1,35 @@
import asyncio
from restreamer.broadcaster import ChannelBroadcaster
async def test_publish_fans_out_to_all_subscribers() -> None:
broadcaster = ChannelBroadcaster("test")
first = broadcaster.subscribe()
second = broadcaster.subscribe()
broadcaster.publish(b"chunk")
assert await first.get() == b"chunk"
assert await second.get() == b"chunk"
async def test_unsubscribe_stops_receiving() -> None:
broadcaster = ChannelBroadcaster("test")
queue = broadcaster.subscribe()
broadcaster.unsubscribe(queue)
broadcaster.publish(b"chunk")
assert queue.empty()
async def test_full_queue_drops_slow_subscriber() -> None:
broadcaster = ChannelBroadcaster("test")
queue = broadcaster.subscribe()
for _ in range(queue.maxsize + 1):
broadcaster.publish(b"x")
await asyncio.sleep(0)
assert broadcaster.subscriber_count == 0
+51
View File
@@ -0,0 +1,51 @@
from pathlib import Path
import pytest
from restreamer.config import load_channels
def test_load_channels_parses_valid_file(tmp_path: Path) -> None:
channels_file = tmp_path / "channels.yaml"
channels_file.write_text(
"""
channels:
- id: news
name: "News Live"
url: "https://www.youtube.com/watch?v=abc123"
- id: music
name: "Music Live"
url: "https://www.youtube.com/watch?v=def456"
refresh_interval_seconds: 3600
""",
encoding="utf-8",
)
channels = load_channels(channels_file)
assert [c.id for c in channels] == ["news", "music"]
assert channels[1].refresh_interval_seconds == 3600
def test_load_channels_missing_file_raises(tmp_path: Path) -> None:
with pytest.raises(FileNotFoundError):
load_channels(tmp_path / "does_not_exist.yaml")
def test_load_channels_rejects_duplicate_ids(tmp_path: Path) -> None:
channels_file = tmp_path / "channels.yaml"
channels_file.write_text(
"""
channels:
- id: news
name: "News Live"
url: "https://www.youtube.com/watch?v=abc123"
- id: news
name: "News Live Again"
url: "https://www.youtube.com/watch?v=def456"
""",
encoding="utf-8",
)
with pytest.raises(ValueError, match="Duplicate channel id"):
load_channels(channels_file)
+44
View File
@@ -0,0 +1,44 @@
from restreamer.ffmpeg_runner import build_ffmpeg_args
from restreamer.resolver import ResolvedStream
def test_build_ffmpeg_args_single_input_without_headers() -> None:
stream = ResolvedStream(video_url="https://example.com/stream.m3u8")
args = build_ffmpeg_args(stream)
assert args[0] == "ffmpeg"
assert "-headers" not in args
assert args.count("-i") == 1
assert stream.video_url in args
assert args[-1] == "pipe:1"
def test_build_ffmpeg_args_includes_headers_when_present() -> None:
stream = ResolvedStream(
video_url="https://example.com/stream.m3u8",
http_headers={"User-Agent": "test-agent", "Cookie": "a=b"},
)
args = build_ffmpeg_args(stream)
assert "-headers" in args
header_value = args[args.index("-headers") + 1]
assert "User-Agent: test-agent\r\n" in header_value
assert "Cookie: a=b\r\n" in header_value
def test_build_ffmpeg_args_maps_separate_video_and_audio_inputs() -> None:
stream = ResolvedStream(
video_url="https://example.com/video.m3u8",
audio_url="https://example.com/audio.m3u8",
)
args = build_ffmpeg_args(stream)
assert args.count("-i") == 2
assert stream.video_url in args
assert stream.audio_url in args
assert "-map" in args
map_values = [args[i + 1] for i, a in enumerate(args) if a == "-map"]
assert map_values == ["0:v:0", "1:a:0"]
+19
View File
@@ -0,0 +1,19 @@
from pydantic import HttpUrl
from restreamer.config import ChannelConfig
from restreamer.http_server import build_playlist
def test_build_playlist_lists_all_channels() -> None:
channels = [
ChannelConfig(id="news", name="News Live", url=HttpUrl("https://www.youtube.com/watch?v=abc123")),
ChannelConfig(id="music", name="Music Live", url=HttpUrl("https://www.youtube.com/watch?v=def456")),
]
playlist = build_playlist(channels, "http://localhost:8000/")
assert playlist.startswith("#EXTM3U\n")
assert 'tvg-id="news"' in playlist
assert "http://localhost:8000/stream/news" in playlist
assert "http://localhost:8000/stream/music" in playlist
assert "http://localhost:8000//stream" not in playlist
+23
View File
@@ -0,0 +1,23 @@
from datetime import UTC, datetime
from restreamer.resolver import parse_expiry_from_url
def test_parse_expiry_from_url_reads_expire_param() -> None:
url = "https://r.googlevideo.com/videoplayback?id=abc&expire=1700000000&itag=96"
result = parse_expiry_from_url(url)
assert result == datetime.fromtimestamp(1700000000, tz=UTC)
def test_parse_expiry_from_url_returns_none_without_param() -> None:
url = "https://example.com/stream.m3u8?token=abc"
assert parse_expiry_from_url(url) is None
def test_parse_expiry_from_url_ignores_non_numeric_value() -> None:
url = "https://example.com/stream.m3u8?expire=not-a-number"
assert parse_expiry_from_url(url) is None