From 4fbb3e813e531ab85bbee1ab2c5b869e099420e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Sun, 27 Sep 2026 18:09:26 +0200 Subject: [PATCH] Fix stream keep-alive handling; bump to v1.0.0 Force-close each /stream/{channel_id} response instead of allowing keep-alive reuse. Jellyfin's SharedHttpStream tuner client was reusing a pooled keep-alive connection mid-stream from an earlier request and reading raw MPEG-TS bytes as an HTTP status line, failing with 'Received an invalid status line'. Also broaden the disconnect exception handling to ConnectionAbortedError alongside ConnectionResetError. --- CHANGELOG.md | 10 ++++++++++ pyproject.toml | 2 +- src/restreamer/http_server.py | 6 +++++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99d8805..97e48eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,13 @@ All notable changes to this project are documented in this file. (`channels.example.yaml`, `.env.example`). - Unit test suite covering config parsing, URL expiry parsing, ffmpeg argument building, playlist generation, and broadcast fan-out. + +### Fixed + +- `/stream/{channel_id}` now forces the connection closed after each response (`Connection: close`) instead of + allowing keep-alive. Jellyfin's M3U tuner client (`SharedHttpStream`) was reusing a pooled keep-alive connection + still mid-stream from an earlier request and reading raw MPEG-TS bytes as if they were a fresh HTTP response, + failing with "Received an invalid status line". +- The yt-dlp resolver now correctly classifies which of the two `requested_formats` entries is audio when a live + stream is split into separate video-only and audio-only renditions; it previously relied on `acodec` being set, + which yt-dlp does not consistently do for audio-only formats, causing every resolve to fail. diff --git a/pyproject.toml b/pyproject.toml index 4ddecb7..a7126ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "restreamer" -version = "0.1.0" +version = "1.0.0" description = "Restreams YouTube livestreams as continuous local streams consumable by Jellyfin Live TV" authors = [ {name = "Jan Doubravsky",email = "jan.doubravsky@gmail.com"} diff --git a/src/restreamer/http_server.py b/src/restreamer/http_server.py index a2e309c..ed04f8a 100644 --- a/src/restreamer/http_server.py +++ b/src/restreamer/http_server.py @@ -42,6 +42,10 @@ async def _handle_stream(request: web.Request) -> web.StreamResponse: raise web.HTTPNotFound(text=f"Unknown channel '{channel_id}'") response = web.StreamResponse(status=200, headers={"Content-Type": MPEGTS_CONTENT_TYPE}) + # A live stream is never meant to be pooled and reused for a later request; some HTTP + # clients (e.g. Jellyfin's SharedHttpStream) get fed stale bytes from a dangling + # keep-alive connection if we let one linger here. Force a fresh connection every time. + response.force_close() await response.prepare(request) queue = broadcaster.subscribe() @@ -51,7 +55,7 @@ async def _handle_stream(request: web.Request) -> web.StreamResponse: if chunk is None: break await response.write(chunk) - except ConnectionResetError: + except (ConnectionResetError, ConnectionAbortedError): logger.debug("Client disconnected from channel '{}'", channel_id) finally: broadcaster.unsubscribe(queue)