From 3383d5d5dc285bfa062bf89d7697c5e1074b1622 Mon Sep 17 00:00:00 2001 From: arabcoders Date: Tue, 27 Jan 2026 17:42:12 +0300 Subject: [PATCH] refactor: move streaming logic to a feature submodule --- app/features/streaming/__init__.py | 0 .../streaming}/library/ffprobe.py | 7 +- .../streaming/library/m3u8.py} | 4 +- .../streaming/library/playlist.py} | 5 +- .../streaming/library/segment_encoders.py} | 4 +- .../streaming/library/segments.py} | 8 +- .../streaming/library/subtitle.py} | 2 +- app/features/streaming/router.py | 279 ++++++++++++++++++ .../streaming}/tests/test_ffprobe.py | 12 +- .../streaming}/tests/test_m3u8.py | 10 +- .../streaming}/tests/test_playlist.py | 20 +- .../streaming}/tests/test_segments.py | 41 +-- .../streaming}/tests/test_subtitle.py | 10 +- app/features/streaming/types.py | 6 + app/library/Utils.py | 8 - app/library/downloads/status_tracker.py | 3 +- app/routes/api/browser.py | 2 +- app/routes/api/history.py | 2 +- app/routes/api/player.py | 279 +----------------- app/tests/test_utils.py | 16 - 20 files changed, 353 insertions(+), 365 deletions(-) create mode 100644 app/features/streaming/__init__.py rename app/{ => features/streaming}/library/ffprobe.py (98%) rename app/{library/M3u8.py => features/streaming/library/m3u8.py} (96%) rename app/{library/Playlist.py => features/streaming/library/playlist.py} (90%) rename app/{library/SegmentEncoders.py => features/streaming/library/segment_encoders.py} (98%) rename app/{library/Segments.py => features/streaming/library/segments.py} (97%) rename app/{library/Subtitle.py => features/streaming/library/subtitle.py} (95%) create mode 100644 app/features/streaming/router.py rename app/{ => features/streaming}/tests/test_ffprobe.py (94%) rename app/{ => features/streaming}/tests/test_m3u8.py (92%) rename app/{ => features/streaming}/tests/test_playlist.py (79%) rename app/{ => features/streaming}/tests/test_segments.py (87%) rename app/{ => features/streaming}/tests/test_subtitle.py (88%) create mode 100644 app/features/streaming/types.py diff --git a/app/features/streaming/__init__.py b/app/features/streaming/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/library/ffprobe.py b/app/features/streaming/library/ffprobe.py similarity index 98% rename from app/library/ffprobe.py rename to app/features/streaming/library/ffprobe.py index 5d1ea913..e59a7e8c 100644 --- a/app/library/ffprobe.py +++ b/app/features/streaming/library/ffprobe.py @@ -13,13 +13,10 @@ from pathlib import Path import anyio +from app.features.streaming.types import FFProbeError from app.library.Utils import timed_lru_cache -LOG: logging.Logger = logging.getLogger(__name__) - - -class FFProbeError(Exception): - pass +LOG: logging.Logger = logging.getLogger("streaming.ffprobe") class FFStream: diff --git a/app/library/M3u8.py b/app/features/streaming/library/m3u8.py similarity index 96% rename from app/library/M3u8.py rename to app/features/streaming/library/m3u8.py index 3bbe3c00..818065b3 100644 --- a/app/library/M3u8.py +++ b/app/features/streaming/library/m3u8.py @@ -2,8 +2,8 @@ import math from pathlib import Path from urllib.parse import quote -from .ffprobe import ffprobe -from .Utils import StreamingError +from app.features.streaming.library.ffprobe import ffprobe +from app.features.streaming.types import StreamingError class M3u8: diff --git a/app/library/Playlist.py b/app/features/streaming/library/playlist.py similarity index 90% rename from app/library/Playlist.py rename to app/features/streaming/library/playlist.py index e43bf23f..d2f0554b 100644 --- a/app/library/Playlist.py +++ b/app/features/streaming/library/playlist.py @@ -1,8 +1,9 @@ from pathlib import Path from urllib.parse import quote -from .ffprobe import ffprobe -from .Utils import StreamingError, get_file_sidecar +from app.features.streaming.library.ffprobe import ffprobe +from app.features.streaming.types import StreamingError +from app.library.Utils import get_file_sidecar class Playlist: diff --git a/app/library/SegmentEncoders.py b/app/features/streaming/library/segment_encoders.py similarity index 98% rename from app/library/SegmentEncoders.py rename to app/features/streaming/library/segment_encoders.py index f3bec037..af943a0b 100644 --- a/app/library/SegmentEncoders.py +++ b/app/features/streaming/library/segment_encoders.py @@ -85,7 +85,7 @@ def ffmpeg_encoders() -> set[str]: set[str]: A set of available ffmpeg encoder names. """ - from .config import SUPPORTED_CODECS + from app.library.config import SUPPORTED_CODECS try: result: subprocess.CompletedProcess[str] = subprocess.run( @@ -122,7 +122,7 @@ def select_encoder(configured: str) -> str: str: The selected concrete encoder name. """ - from .config import SUPPORTED_CODECS + from app.library.config import SUPPORTED_CODECS configured = (configured or "").strip() diff --git a/app/library/Segments.py b/app/features/streaming/library/segments.py similarity index 97% rename from app/library/Segments.py rename to app/features/streaming/library/segments.py index 71460606..62c1890f 100644 --- a/app/library/Segments.py +++ b/app/features/streaming/library/segments.py @@ -10,21 +10,21 @@ from typing import TYPE_CHECKING, ClassVar from aiohttp import web -from .config import SUPPORTED_CODECS, Config -from .ffprobe import ffprobe -from .SegmentEncoders import ( +from app.features.streaming.library.ffprobe import ffprobe +from app.features.streaming.library.segment_encoders import ( detect_qsv_capabilities, encoder_fallback_chain, get_builder_for_codec, has_dri_devices, select_encoder, ) +from app.library.config import SUPPORTED_CODECS, Config if TYPE_CHECKING: from asyncio.subprocess import Process from .ffprobe import FFProbeResult - from .SegmentEncoders import EncoderBuilder + from .segment_encoders import EncoderBuilder LOG: logging.Logger = logging.getLogger("player.segments") diff --git a/app/library/Subtitle.py b/app/features/streaming/library/subtitle.py similarity index 95% rename from app/library/Subtitle.py rename to app/features/streaming/library/subtitle.py index 2cda849e..b44fe645 100644 --- a/app/library/Subtitle.py +++ b/app/features/streaming/library/subtitle.py @@ -6,7 +6,7 @@ import pysubs2 from pysubs2.formats.substation import SubstationFormat from pysubs2.time import ms_to_times -from .Utils import ALLOWED_SUBS_EXTENSIONS +from app.library.Utils import ALLOWED_SUBS_EXTENSIONS LOG: logging.Logger = logging.getLogger("player.subtitle") diff --git a/app/features/streaming/router.py b/app/features/streaming/router.py new file mode 100644 index 00000000..687d8e5c --- /dev/null +++ b/app/features/streaming/router.py @@ -0,0 +1,279 @@ +import logging +import time +from datetime import UTC, datetime +from pathlib import Path + +from aiohttp import web +from aiohttp.web import Request, Response + +from app.features.streaming.library.m3u8 import M3u8 +from app.features.streaming.library.playlist import Playlist +from app.features.streaming.library.segments import Segments +from app.features.streaming.library.subtitle import Subtitle +from app.features.streaming.types import StreamingError +from app.library.config import Config +from app.library.router import route +from app.library.Utils import get_file + +LOG: logging.Logger = logging.getLogger("streaming") + + +@route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist_create") +async def playlist_create(request: Request, config: Config, app: web.Application) -> Response: + """ + Get the playlist. + + Args: + request (Request): The request object. + config (Config): The configuration instance. + app (web.Application): The aiohttp application instance. + + Returns: + Response: The response object. + + """ + file: str = request.match_info.get("file") + + if not file: + return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) + + base_path: str = config.base_path.rstrip("/") + + try: + realFile, status = get_file(download_path=config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=web.HTTPFound.status_code, + headers={ + "Location": str( + app.router["playlist_create"].url_for( + file=str(realFile).replace(config.download_path, "").strip("/") + ) + ), + }, + ) + + if web.HTTPNotFound.status_code == status: + return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) + + return web.Response( + text=await Playlist(download_path=Path(config.download_path), url=f"{base_path}/").make(file=realFile), + headers={ + "Content-Type": "application/x-mpegURL", + "Cache-Control": "no-cache", + "Access-Control-Max-Age": "300", + }, + status=web.HTTPOk.status_code, + ) + except StreamingError as e: + return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code) + + +@route("GET", "api/player/m3u8/{mode}/{file:.*}.m3u8", "m3u8_create") +async def m3u8_create(request: Request, config: Config, app: web.Application) -> Response: + """ + Get the m3u8 file. + + Args: + request (Request): The request object. + config (Config): The configuration instance. + app (web.Application): The aiohttp application instance. + + Returns: + Response: The response object. + + """ + file: str = request.match_info.get("file") + mode: str = request.match_info.get("mode") + + if mode not in ["video", "subtitle"]: + return web.json_response( + data={"error": "Only video and subtitle modes are supported."}, status=web.HTTPBadRequest.status_code + ) + + if not file: + return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) + + duration = request.query.get("duration", None) + + if "subtitle" in mode: + if not duration: + return web.json_response(data={"error": "duration is required."}, status=web.HTTPBadRequest.status_code) + + duration = float(duration) + + base_path: str = config.base_path.rstrip("/") + + try: + cls = M3u8(download_path=Path(config.download_path), url=f"{base_path}/") + + realFile, status = get_file(download_path=config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": str( + app.router["m3u8_create"].url_for( + mode=mode, file=str(realFile).replace(config.download_path, "").strip("/") + ) + ), + }, + ) + + if web.HTTPNotFound.status_code == status: + return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) + + if "subtitle" in mode: + text = await cls.make_subtitle(file=realFile, duration=duration) + else: + text = await cls.make_stream(file=realFile) + except StreamingError as e: + LOG.exception(e) + return web.json_response(data={"error": str(e)}, status=web.HTTPNotFound.status_code) + + return web.Response( + text=text, + headers={ + "Content-Type": "application/x-mpegURL", + "Cache-Control": "no-cache", + "Access-Control-Max-Age": "300", + }, + status=web.HTTPOk.status_code, + ) + + +@route("GET", r"api/player/segments/{segment:\d+}/{file:.*}.ts", "segments_stream") +async def segments_stream(request: Request, config: Config, app: web.Application) -> Response: + """ + Get the segments. + + Args: + request (Request): The request object. + config (Config): The configuration instance. + app (web.Application): The aiohttp application instance. + + Returns: + Response: The response object. + + """ + file: str = request.match_info.get("file") + segment: int = request.match_info.get("segment") + sd: int = request.query.get("sd") + vc: int = int(request.query.get("vc", 0)) + ac: int = int(request.query.get("ac", 0)) + + if not file: + return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) + + if not segment: + return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code) + + realFile, status = get_file(download_path=config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": str( + app.router["segments_stream"].url_for( + segment=segment, + file=str(realFile).replace(config.download_path, "").strip("/"), + ) + ), + }, + ) + + if web.HTTPNotFound.status_code == status: + return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) + + mtime = realFile.stat().st_mtime + + if request.if_modified_since and request.if_modified_since.timestamp() == mtime: + lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()) + return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod}) + + resp = web.StreamResponse( + status=web.HTTPOk.status_code, + headers={ + "Content-Type": "video/mpegts", + "X-Accel-Buffering": "no", + "Access-Control-Allow-Origin": "*", + "Pragma": "public", + "Cache-Control": f"public, max-age={time.time() + 31536000}", + "Last-Modified": time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple() + ), + "Expires": time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple() + ), + }, + ) + + await resp.prepare(request) + + await Segments( + download_path=config.download_path, + index=int(segment), + duration=float(f"{float(sd if sd else M3u8.duration):.6f}"), + vconvert=vc == 1, + aconvert=ac == 1, + ).stream(realFile, resp) + + return resp + + +@route("GET", "api/player/subtitle/{file:.*}.vtt", "subtitles_get") +async def subtitles_get(request: Request, config: Config, app: web.Application) -> Response: + """ + Get the subtitles. + + Args: + request (Request): The request object. + config (Config): The configuration instance. + app (web.Application): The aiohttp application instance. + + Returns: + Response: The response object. + + """ + file: str = request.match_info.get("file") + + if not file: + return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) + + realFile, status = get_file(download_path=config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": str( + app.router["subtitles_get"].url_for(file=str(realFile).replace(config.download_path, "").strip("/")) + ), + }, + ) + + if web.HTTPNotFound.status_code == status: + return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) + + mtime = realFile.stat().st_mtime + + if request.if_modified_since and request.if_modified_since.timestamp() == mtime: + lastMod = time.strftime("%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple()) + return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod}) + + return web.Response( + body=await Subtitle().make(file=realFile), + headers={ + "Content-Type": "text/vtt; charset=UTF-8", + "X-Accel-Buffering": "no", + "Access-Control-Allow-Origin": "*", + "Pragma": "public", + "Cache-Control": f"public, max-age={time.time() + 31536000}", + "Last-Modified": time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(mtime, tz=UTC).timetuple() + ), + "Expires": time.strftime( + "%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(time.time() + 31536000, tz=UTC).timetuple() + ), + }, + status=web.HTTPOk.status_code, + ) diff --git a/app/tests/test_ffprobe.py b/app/features/streaming/tests/test_ffprobe.py similarity index 94% rename from app/tests/test_ffprobe.py rename to app/features/streaming/tests/test_ffprobe.py index 8d114970..1ef7c549 100644 --- a/app/tests/test_ffprobe.py +++ b/app/features/streaming/tests/test_ffprobe.py @@ -23,7 +23,7 @@ class TestFFProbe: @pytest.mark.asyncio async def test_ffprobe_with_existing_file(self): """Test ffprobe with an existing file.""" - from app.library.ffprobe import FFProbeResult, ffprobe + from app.features.streaming.library.ffprobe import FFProbeResult, ffprobe # Mock subprocess to avoid actual ffprobe execution with patch("asyncio.create_subprocess_exec") as mock_subprocess: @@ -43,7 +43,7 @@ class TestFFProbe: @pytest.mark.asyncio async def test_ffprobe_with_nonexistent_file(self): """Test ffprobe with a non-existent file.""" - from app.library.ffprobe import ffprobe + from app.features.streaming.library.ffprobe import ffprobe nonexistent_file = Path(self.temp_dir) / "does_not_exist.mp4" @@ -53,7 +53,7 @@ class TestFFProbe: @pytest.mark.asyncio async def test_ffprobe_caching_behavior(self): """Test that ffprobe results are cached with enhanced async timed_lru_cache.""" - from app.library.ffprobe import ffprobe + from app.features.streaming.library.ffprobe import ffprobe assert hasattr(ffprobe, "cache_clear"), ( "Test that the function has been decorated with caching - ffprobe should have cache_clear method from timed_lru_cache" @@ -103,7 +103,7 @@ class TestFFProbe: @pytest.mark.asyncio async def test_ffprobe_with_path_object(self): """Test ffprobe with Path object input.""" - from app.library.ffprobe import ffprobe + from app.features.streaming.library.ffprobe import ffprobe # Mock subprocess to avoid actual ffprobe execution with patch("asyncio.create_subprocess_exec") as mock_subprocess: @@ -122,7 +122,7 @@ class TestFFProbe: def test_ffprobe_result_properties(self): """Test FFProbeResult object properties.""" - from app.library.ffprobe import FFProbeResult, FFStream + from app.features.streaming.library.ffprobe import FFStream, FFProbeResult result = FFProbeResult() @@ -149,7 +149,7 @@ class TestFFProbe: def test_stream_object_methods(self): """Test Stream object methods.""" - from app.library.ffprobe import FFStream + from app.features.streaming.library.ffprobe import FFStream # Test video stream video_stream = FFStream( diff --git a/app/tests/test_m3u8.py b/app/features/streaming/tests/test_m3u8.py similarity index 92% rename from app/tests/test_m3u8.py rename to app/features/streaming/tests/test_m3u8.py index c703b781..2e97da56 100644 --- a/app/tests/test_m3u8.py +++ b/app/features/streaming/tests/test_m3u8.py @@ -4,8 +4,8 @@ from urllib.parse import quote import pytest -from app.library.M3u8 import M3u8 -from app.library.Utils import StreamingError +from app.features.streaming.library.m3u8 import M3u8 +from app.features.streaming.types import StreamingError class _Stream: @@ -38,7 +38,7 @@ async def test_make_stream_basic_ok_codecs(tmp_path: Path, monkeypatch: pytest.M ], ) - monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe) m3 = M3u8(download_path=base, url="http://host/") out = await m3.make_stream(media) @@ -79,7 +79,7 @@ async def test_make_stream_transcode_flags_and_remainder(tmp_path: Path, monkeyp ], ) - monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe) m3 = M3u8(download_path=base, url="https://s/") out = await m3.make_stream(media) @@ -116,7 +116,7 @@ async def test_make_stream_raises_without_duration(tmp_path: Path, monkeypatch: async def fake_ffprobe(_file: Path): return SimpleNamespace(metadata={}) - monkeypatch.setattr("app.library.M3u8.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.m3u8.ffprobe", fake_ffprobe) m3 = M3u8(download_path=base, url="http://s/") with pytest.raises(StreamingError, match="Unable to get"): diff --git a/app/tests/test_playlist.py b/app/features/streaming/tests/test_playlist.py similarity index 79% rename from app/tests/test_playlist.py rename to app/features/streaming/tests/test_playlist.py index e9bb2f83..f69025ea 100644 --- a/app/tests/test_playlist.py +++ b/app/features/streaming/tests/test_playlist.py @@ -4,8 +4,8 @@ from urllib.parse import quote import pytest -from app.library.Playlist import Playlist -from app.library.Utils import StreamingError +from app.features.streaming.library.playlist import Playlist +from app.features.streaming.types import StreamingError @pytest.mark.asyncio @@ -21,14 +21,14 @@ async def test_make_playlist_no_subtitles(tmp_path: Path, monkeypatch: pytest.Mo async def fake_ffprobe(_file: Path): return SimpleNamespace(metadata={"duration": "60"}) - monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe) # No sidecar subtitles - monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []}) + monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: {"subtitle": []}) # Patch module-level quote to be robust for Path objects from urllib.parse import quote as _std_quote - monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v))) + monkeypatch.setattr("app.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v))) pl = Playlist(download_path=base, url="http://localhost/") out = await pl.make(media) @@ -54,7 +54,7 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest. async def fake_ffprobe(_file: Path): return SimpleNamespace(metadata={"duration": "12.5"}) - monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe) # Build two subtitle sidecars with names and langs; ensure quoting/relative name used sub1 = media.with_suffix(".en.srt") @@ -65,11 +65,11 @@ async def test_make_playlist_with_subtitles(tmp_path: Path, monkeypatch: pytest. {"lang": "fr", "file": sub2, "name": "French"}, ] } - monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: sidecar) + monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: sidecar) from urllib.parse import quote as _std_quote - monkeypatch.setattr("app.library.Playlist.quote", lambda v: _std_quote(str(v))) + monkeypatch.setattr("app.features.streaming.library.playlist.quote", lambda v: _std_quote(str(v))) pl = Playlist(download_path=base, url="https://server/") out = await pl.make(media) @@ -109,8 +109,8 @@ async def test_make_playlist_raises_without_duration(tmp_path: Path, monkeypatch async def fake_ffprobe(_file: Path): return SimpleNamespace(metadata={}) - monkeypatch.setattr("app.library.Playlist.ffprobe", fake_ffprobe) - monkeypatch.setattr("app.library.Playlist.get_file_sidecar", lambda _f: {"subtitle": []}) + monkeypatch.setattr("app.features.streaming.library.playlist.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.playlist.get_file_sidecar", lambda _f: {"subtitle": []}) pl = Playlist(download_path=base, url="http://localhost/") diff --git a/app/tests/test_segments.py b/app/features/streaming/tests/test_segments.py similarity index 87% rename from app/tests/test_segments.py rename to app/features/streaming/tests/test_segments.py index da10e629..c2bb160e 100644 --- a/app/tests/test_segments.py +++ b/app/features/streaming/tests/test_segments.py @@ -7,7 +7,7 @@ from typing import Any import pytest -from app.library.Segments import Segments +from app.features.streaming.library.segments import Segments class DummyFF: @@ -32,7 +32,7 @@ async def test_build_ffmpeg_args_video_and_audio(tmp_path: Path, monkeypatch: py async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) seg = Segments(download_path=str(tmp_path), index=2, duration=5.5, vconvert=False, aconvert=False) @@ -84,7 +84,7 @@ async def test_build_ffmpeg_args_audio_only(tmp_path: Path, monkeypatch: pytest. async def fake_ffprobe(_file: Path): return DummyFF(v=False, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) seg = Segments(download_path=str(tmp_path), index=0, duration=9.0, vconvert=False, aconvert=False) @@ -175,13 +175,16 @@ async def test_build_ffmpeg_args_no_dri_falls_back_to_software(tmp_path: Path, m async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) # Simulate no /dev/dri present but GPU encoders otherwise available - monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: False) - monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}) + monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: False) + monkeypatch.setattr( + "app.features.streaming.library.segment_encoders.ffmpeg_encoders", + lambda: {"h264_nvenc", "h264_qsv", "h264_amf"}, + ) # reset encoder cache to ensure clean selection in this test - from app.library.Segments import Segments as _Seg + from app.features.streaming.library.segments import Segments as _Seg _Seg._cached_vcodec = None _Seg._cache_initialized = False @@ -218,10 +221,10 @@ async def test_stream_gpu_failure_falls_back_to_software( async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) # Allow GPU usage and advertise an NVENC encoder so first pick is GPU - monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True) - monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_nvenc"}) + monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: True) + monkeypatch.setattr("app.features.streaming.library.segment_encoders.ffmpeg_encoders", lambda: {"h264_nvenc"}) # First process fails (no data, rc=1), second succeeds and outputs bytes proc_fail = _FakeProcFail(err=b"nvenc failure: encoder not available") @@ -239,7 +242,7 @@ async def test_stream_gpu_failure_falls_back_to_software( monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) # reset encoder cache to ensure we try GPU first - from app.library.Segments import Segments as _Seg + from app.features.streaming.library.segments import Segments as _Seg _Seg._cached_vcodec = None _Seg._cache_initialized = False @@ -270,12 +273,12 @@ async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatc async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) # Only QSV advertised so initial build sets QSV # Patch both where it's defined AND where it's imported/used - monkeypatch.setattr("app.library.SegmentEncoders.has_dri_devices", lambda: True) - monkeypatch.setattr("app.library.Segments.has_dri_devices", lambda: True) - monkeypatch.setattr("app.library.SegmentEncoders.ffmpeg_encoders", lambda: {"h264_qsv"}) + monkeypatch.setattr("app.features.streaming.library.segment_encoders.has_dri_devices", lambda: True) + monkeypatch.setattr("app.features.streaming.library.segments.has_dri_devices", lambda: True) + monkeypatch.setattr("app.features.streaming.library.segment_encoders.ffmpeg_encoders", lambda: {"h264_qsv"}) # Fail first, succeed second proc_fail = _FakeProcFail(err=b"qsv failure") @@ -290,7 +293,7 @@ async def test_stream_gpu_fallback_switches_codec(monkeypatch: pytest.MonkeyPatc monkeypatch.setattr("asyncio.create_subprocess_exec", fake_create_subprocess_exec) # reset cache - from app.library.Segments import Segments as _Seg + from app.features.streaming.library.segments import Segments as _Seg _Seg._cached_vcodec = None _Seg._cache_initialized = False @@ -338,7 +341,7 @@ async def test_stream_normal_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Pat async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) # Process that yields two chunks and then EOF proc = _FakeProc([b"abc", b"def", b""]) @@ -361,7 +364,7 @@ async def test_stream_client_reset(monkeypatch: pytest.MonkeyPatch, tmp_path: Pa async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) proc = _FakeProc([b"abc", b"def"]) # will attempt to write and fail @@ -384,7 +387,7 @@ async def test_stream_cancelled(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) async def fake_ffprobe(_file: Path): return DummyFF(v=True, a=True) - monkeypatch.setattr("app.library.Segments.ffprobe", fake_ffprobe) + monkeypatch.setattr("app.features.streaming.library.segments.ffprobe", fake_ffprobe) proc = _FakeProc([b"abc"]) # only one chunk diff --git a/app/tests/test_subtitle.py b/app/features/streaming/tests/test_subtitle.py similarity index 88% rename from app/tests/test_subtitle.py rename to app/features/streaming/tests/test_subtitle.py index de5ce860..8e06d03b 100644 --- a/app/tests/test_subtitle.py +++ b/app/features/streaming/tests/test_subtitle.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from app.library.Subtitle import Subtitle, ms_to_timestamp +from app.features.streaming.library.subtitle import Subtitle, ms_to_timestamp class TestMsToTimestamp: @@ -59,7 +59,7 @@ async def test_make_no_events_raises(tmp_path: Path) -> None: srt = tmp_path / "sub.srt" srt.write_text("dummy") - with patch("app.library.Subtitle.pysubs2.load") as mock_load: + with patch("app.features.streaming.library.subtitle.pysubs2.load") as mock_load: mock_load.return_value = _DummySubs(events=[]) sub = Subtitle() with pytest.raises(Exception, match="No subtitle events were found"): @@ -74,7 +74,7 @@ async def test_make_single_event_returns_vtt(tmp_path: Path) -> None: single = SimpleNamespace(end=1000) d = _DummySubs(events=[single]) - with patch("app.library.Subtitle.pysubs2.load", return_value=d): + with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): sub = Subtitle() out = await sub.make(srt) assert out == "OUT" @@ -90,7 +90,7 @@ async def test_make_two_events_pop_first_when_ends_equal(tmp_path: Path) -> None e2 = SimpleNamespace(end=5000) d = _DummySubs(events=[e1, e2]) - with patch("app.library.Subtitle.pysubs2.load", return_value=d): + with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): sub = Subtitle() out = await sub.make(srt) assert out == "OUT" @@ -106,7 +106,7 @@ async def test_make_two_events_no_pop_when_different(tmp_path: Path) -> None: e2 = SimpleNamespace(end=6000) d = _DummySubs(events=[e1, e2]) - with patch("app.library.Subtitle.pysubs2.load", return_value=d): + with patch("app.features.streaming.library.subtitle.pysubs2.load", return_value=d): sub = Subtitle() out = await sub.make(srt) assert out == "OUT" diff --git a/app/features/streaming/types.py b/app/features/streaming/types.py new file mode 100644 index 00000000..d00cc791 --- /dev/null +++ b/app/features/streaming/types.py @@ -0,0 +1,6 @@ +class StreamingError(Exception): + """Raised when an error occurs during streaming.""" + + +class FFProbeError(Exception): + """Raised when an error occurs during FFProbe processing.""" diff --git a/app/library/Utils.py b/app/library/Utils.py index fbc14a47..66e178fc 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -85,10 +85,6 @@ DT_PATTERN: re.Pattern[str] = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2} "Regex to match ISO 8601 datetime strings." -class StreamingError(Exception): - """Raised when an error occurs during streaming.""" - - class FileLogFormatter(logging.Formatter): def formatTime(self, record, datefmt=None): # noqa: ARG002, N802 return datetime.fromtimestamp(record.created).astimezone().isoformat(timespec="milliseconds") @@ -851,10 +847,8 @@ def get_mime_type(metadata: dict, file_path: Path) -> str: str: MIME type compatible with HTML5