Add initial Restreamer implementation for YouTube-to-Jellyfin live restreaming
This commit is contained in:
@@ -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=
|
||||||
@@ -174,3 +174,7 @@ cython_debug/
|
|||||||
# PyPI configuration file
|
# PyPI configuration file
|
||||||
.pypirc
|
.pypirc
|
||||||
|
|
||||||
|
# Restreamer runtime
|
||||||
|
logs/
|
||||||
|
channels.yaml
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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.
|
||||||
@@ -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`)
|
||||||
@@ -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
@@ -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
@@ -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.
|
||||||
@@ -1,2 +1,41 @@
|
|||||||
# Restreamer
|
# 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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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]})
|
||||||
@@ -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}",
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user