refactor: move streaming logic to a feature submodule

This commit is contained in:
arabcoders 2026-01-27 17:42:12 +03:00
parent 240a920b6c
commit 3383d5d5dc
20 changed files with 353 additions and 365 deletions

View file

View file

@ -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:

View file

@ -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:

View file

@ -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:

View file

@ -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()

View file

@ -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")

View file

@ -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")

View file

@ -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,
)

View file

@ -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(

View file

@ -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"):

View file

@ -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/")

View file

@ -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

View file

@ -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"

View file

@ -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."""

View file

@ -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 <video> tag.
"""
# Extract format name from ffprobe
format_name = metadata.get("format_name", "")
# Define mappings for HTML5-compatible video types
format_to_mime: dict[str, str] = {
"matroska": "video/x-matroska", # Default for MKV
"webm": "video/webm", # MKV can also be WebM
@ -862,7 +856,6 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
"mpegts": "video/mp2t",
}
# Check format_name against known formats
if format_name:
selected = None
for fmt in format_name.split(","):
@ -873,7 +866,6 @@ def get_mime_type(metadata: dict, file_path: Path) -> str:
if selected:
return selected
# Fallback: Use Python's mimetypes module
import mimetypes
mime_type, _ = mimetypes.guess_type(str(file_path))

View file

@ -8,7 +8,6 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from app.library.Events import EventBus, Events
from app.library.ffprobe import ffprobe
from .types import StatusDict, Terminator
from .utils import safe_relative_path
@ -123,6 +122,8 @@ class StatusTracker:
self.info.file_size = 0
try:
from app.features.streaming.library.ffprobe import ffprobe
ff = await ffprobe(final_name)
self.info.extras["is_video"] = ff.has_video()
self.info.extras["is_audio"] = ff.has_audio()

View file

@ -7,12 +7,12 @@ from urllib.parse import unquote_plus
from aiohttp import web
from aiohttp.web import Request, Response
from app.features.streaming.library.ffprobe import ffprobe
from app.library.cache import Cache
from app.library.config import Config
from app.library.downloads import DownloadQueue
from app.library.encoder import Encoder
from app.library.Events import EventBus, Events
from app.library.ffprobe import ffprobe
from app.library.router import route
from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type, move_file, rename_file

View file

@ -264,7 +264,7 @@ async def item_view(request: Request, queue: DownloadQueue, encoder: Encoder) ->
}
if "finished" == item.info.status and (filename := item.info.get_file()):
from app.library.ffprobe import ffprobe
from app.features.streaming.library.ffprobe import ffprobe
try:
info["ffprobe"] = await ffprobe(filename)

View file

@ -1,278 +1,3 @@
import logging
import time
from datetime import UTC, datetime
from pathlib import Path
"""Migrated API routes for feature submodule."""
from aiohttp import web
from aiohttp.web import Request, Response
from app.library.config import Config
from app.library.M3u8 import M3u8
from app.library.Playlist import Playlist
from app.library.router import route
from app.library.Segments import Segments
from app.library.Subtitle import Subtitle
from app.library.Utils import StreamingError, get_file
LOG: logging.Logger = logging.getLogger(__name__)
@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,
)
import app.features.streaming.router # noqa: F401

View file

@ -13,7 +13,6 @@ import pytest
from app.library.Utils import (
FileLogFormatter,
StreamingError,
archive_add,
archive_delete,
archive_read,
@ -56,21 +55,6 @@ from app.library.Utils import (
from app.library.downloads.extractor import extract_info_sync
class TestStreamingError:
"""Test the StreamingError exception class."""
def test_streaming_error_creation(self):
"""Test that StreamingError can be created with a message."""
error_msg = "Test error message"
error = StreamingError(error_msg)
assert str(error) == error_msg
def test_streaming_error_inheritance(self):
"""Test that StreamingError inherits from Exception."""
error = StreamingError("test")
assert isinstance(error, Exception)
class TestTimedLruCache:
"""Test the timed_lru_cache decorator."""