refactor: move streaming logic to a feature submodule
This commit is contained in:
parent
240a920b6c
commit
3383d5d5dc
20 changed files with 353 additions and 365 deletions
0
app/features/streaming/__init__.py
Normal file
0
app/features/streaming/__init__.py
Normal 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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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:
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -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")
|
||||
|
||||
279
app/features/streaming/router.py
Normal file
279
app/features/streaming/router.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
@ -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"):
|
||||
|
|
@ -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/")
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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"
|
||||
6
app/features/streaming/types.py
Normal file
6
app/features/streaming/types.py
Normal 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."""
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue