diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 5f276f45..afaa3390 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -11,6 +11,7 @@ import uuid from collections.abc import Awaitable from datetime import UTC, datetime from pathlib import Path +from urllib.parse import quote import anyio import httpx @@ -37,7 +38,7 @@ from .Utils import ( IGNORED_KEYS, StreamingError, arg_converter, - calc_download_path, + get_file, get_mime_type, get_sidecar_subtitles, get_video_info, @@ -119,7 +120,7 @@ class HttpAPI(Common): HttpAPI: The instance of the HttpAPI. """ - app.middlewares.append(HttpAPI.middle_wares()) + app.middlewares.append(HttpAPI.middle_wares(download_path=self.config.download_path)) if self.config.auth_username and self.config.auth_password: app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password)) @@ -312,9 +313,19 @@ class HttpAPI(Common): return middleware_handler @staticmethod - def middle_wares() -> Awaitable: + def middle_wares(download_path: str) -> Awaitable: @web.middleware async def middleware_handler(request: Request, handler: RequestHandler) -> Response: + if request.path.startswith("/api/download"): + realFile, status = get_file(download_path=download_path, file=request.path.replace("/api/download/", "")) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": f"/api/download/{quote(str(realFile).replace(download_path, '').strip('/'))}" + }, + ) + response = await handler(request) if isinstance(response, web.FileResponse): @@ -918,25 +929,33 @@ class HttpAPI(Common): file: str = request.match_info.get("file") if not file: - raise web.HTTPBadRequest(text="file is required.") + return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) try: - text = await Playlist(url="/").make(download_path=self.config.download_path, file=file) - if isinstance(text, Response): - return text + realFile, status = get_file(download_path=self.config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=web.HTTPFound.status_code, + headers={ + "Location": f"/api/player/playlist/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.m3u8" + }, + ) + + 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=self.config.download_path, url="/").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) - 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", "api/player/m3u8/{mode}/{file:.*}.m3u8") async def m3u8(self, request: Request) -> Response: """ @@ -969,11 +988,24 @@ class HttpAPI(Common): duration = float(duration) try: - cls = M3u8("/") + cls = M3u8(download_path=self.config.download_path, url="/") + + realFile, status = get_file(download_path=self.config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": f"/api/player/m3u8/{mode}/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.m3u8" + }, + ) + + 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(self.config.download_path, file, duration) + text = await cls.make_subtitle(file=realFile, duration=duration) else: - text = await cls.make_stream(self.config.download_path, file) + 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) @@ -1005,16 +1037,6 @@ class HttpAPI(Common): sd: int = request.query.get("sd") vc: int = int(request.query.get("vc", 0)) ac: int = int(request.query.get("ac", 0)) - file_path: str = os.path.normpath(os.path.join(self.config.download_path, file)) - if not file_path.startswith(self.config.download_path): - return web.json_response(data={"error": "Invalid file path."}, status=web.HTTPBadRequest.status_code) - - if request.if_modified_since: - lastMod = time.strftime( - "%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path), tz=UTC).timetuple() - ) - if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path): - return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod}) if not file: return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) @@ -1022,7 +1044,26 @@ class HttpAPI(Common): if not segment: return web.json_response(data={"error": "segment id is required."}, status=web.HTTPBadRequest.status_code) + realFile, status = get_file(download_path=self.config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": f"/api/player/segments/{segment}/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.ts" + }, + ) + + 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}) + segmenter = Segments( + download_path=self.config.download_path, index=int(segment), duration=float(f"{float(sd if sd else M3u8.duration):.6f}"), vconvert=vc == 1, @@ -1030,7 +1071,7 @@ class HttpAPI(Common): ) return web.Response( - body=await segmenter.stream(path=self.config.download_path, file=file), + body=await segmenter.stream(file=realFile), headers={ "Content-Type": "video/mpegts", "X-Accel-Buffering": "no", @@ -1038,7 +1079,7 @@ class HttpAPI(Common): "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(os.path.getmtime(file_path), tz=UTC).timetuple() + "%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() @@ -1060,22 +1101,30 @@ class HttpAPI(Common): """ file: str = request.match_info.get("file") - file_path: str = os.path.normpath(os.path.join(self.config.download_path, file)) - if not file_path.startswith(self.config.download_path): - return web.json_response(data={"error": "Invalid file path."}, status=web.HTTPBadRequest.status_code) - - if request.if_modified_since: - lastMod = time.strftime( - "%a, %d %b %Y %H:%M:%S GMT", datetime.fromtimestamp(os.path.getmtime(file_path), tz=UTC).timetuple() - ) - if os.path.exists(file_path) and request.if_modified_since.timestamp() == os.path.getmtime(file_path): - return web.Response(status=web.HTTPNotModified.status_code, headers={"Last-Modified": lastMod}) if not file: return web.json_response(data={"error": "file is required"}, status=web.HTTPBadRequest.status_code) + realFile, status = get_file(download_path=self.config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=status, + headers={ + "Location": f"/api/player/subtitle/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}.vtt" + }, + ) + + 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(path=self.config.download_path, file=file), + body=await Subtitle(download_path=self.config.download_path).make(file=realFile), headers={ "Content-Type": "text/vtt; charset=UTF-8", "X-Accel-Buffering": "no", @@ -1083,7 +1132,7 @@ class HttpAPI(Common): "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(os.path.getmtime(file_path), tz=UTC).timetuple() + "%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() @@ -1187,12 +1236,18 @@ class HttpAPI(Common): return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code) try: - realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False) - if not os.path.exists(realFile) or not os.path.isfile(realFile): - return web.json_response( - data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code + realFile, status = get_file(download_path=self.config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=web.HTTPFound.status_code, + headers={ + "Location": f"/api/file/ffprobe/{quote(str(realFile).replace(self.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.json_response( data=await ffprobe(realFile), status=web.HTTPOk.status_code, dumps=self.encoder.encode ) @@ -1216,13 +1271,17 @@ class HttpAPI(Common): return web.json_response(data={"error": "file is required."}, status=web.HTTPBadRequest.status_code) try: - realFile: str = calc_download_path(base_path=self.config.download_path, folder=file, create_path=False) - if not os.path.exists(realFile) or not os.path.isfile(realFile): - return web.json_response( - data={"error": f"File '{file}' does not exist."}, status=web.HTTPNotFound.status_code + realFile, status = get_file(download_path=self.config.download_path, file=file) + if web.HTTPFound.status_code == status: + return Response( + status=web.HTTPFound.status_code, + headers={ + "Location": f"/api/file/info/{quote(str(realFile).replace(self.config.download_path, '').strip('/'))}" + }, ) - realFile = Path(realFile) + if web.HTTPNotFound.status_code == status: + return web.json_response(data={"error": f"File '{file}' does not exist."}, status=status) ff_info = await ffprobe(realFile) diff --git a/app/library/M3u8.py b/app/library/M3u8.py index e72a2a5c..a6b35b2c 100644 --- a/app/library/M3u8.py +++ b/app/library/M3u8.py @@ -1,9 +1,9 @@ import math -import os +from pathlib import Path from urllib.parse import quote from .ffprobe import ffprobe -from .Utils import StreamingError, calc_download_path +from .Utils import StreamingError class M3u8: @@ -12,24 +12,21 @@ class M3u8: ok_vcodecs: tuple = ("h264", "x264", "avc") ok_acodecs: tuple = ("aac", "m4a", "mp3") - def __init__(self, url: str, segment_duration: float | None = None): + def __init__(self, download_path: str, url: str, segment_duration: float | None = None): self.url = url + self.download_path = download_path self.duration = float(segment_duration) if segment_duration is not None else self.duration - async def make_stream(self, download_path: str, file: str) -> str: - realFile: str = calc_download_path(base_path=download_path, folder=file, create_path=False) - - if not os.path.exists(realFile): - error = f"File '{realFile}' does not exist." - raise StreamingError(error) - + async def make_stream(self, file: Path) -> str: try: - ff = await ffprobe(realFile) + ff = await ffprobe(file) except UnicodeDecodeError: pass + file = str(file).replace(self.download_path, "").strip("/") + if "duration" not in ff.metadata: - error = f"Unable to get '{realFile}' play duration." + error = f"Unable to get '{file}' play duration." raise StreamingError(error) duration: float = float(ff.metadata.get("duration")) @@ -70,13 +67,7 @@ class M3u8: return "\n".join(m3u8) - async def make_subtitle(self, download_path: str, file: str, duration: float) -> str: - realFile: str = calc_download_path(base_path=download_path, folder=file, create_path=False) - - if not os.path.exists(realFile): - error = f"File '{realFile}' does not exist." - raise StreamingError(error) - + async def make_subtitle(self, file: Path, duration: float) -> str: m3u8 = [] m3u8.append("#EXTM3U") @@ -85,7 +76,7 @@ class M3u8: m3u8.append("#EXT-X-MEDIA-SEQUENCE:0") m3u8.append("#EXT-X-PLAYLIST-TYPE:VOD") m3u8.append(f"#EXTINF:{duration},") - m3u8.append(f"{self.url}api/player/subtitle/{quote(file)}.vtt") + m3u8.append(f"{self.url}api/player/subtitle/{quote(str(file).replace(self.download_path, '').strip('/'))}.vtt") m3u8.append("#EXT-X-ENDLIST") return "\n".join(m3u8) diff --git a/app/library/Playlist.py b/app/library/Playlist.py index befd9193..316d55ca 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -1,41 +1,27 @@ from pathlib import Path from urllib.parse import quote -from aiohttp.web import HTTPFound, Response - from .ffprobe import ffprobe -from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles +from .Utils import StreamingError, get_sidecar_subtitles class Playlist: _url: str = None - def __init__(self, url: str): + def __init__(self, download_path: str, url: str): self.url = url + self.download_path = download_path - async def make(self, download_path: str, file: str) -> str | Response: - rFile = Path(calc_download_path(base_path=download_path, folder=file, create_path=False)) - - if not rFile.exists(): - possibleFile = check_id(file=rFile) - if not possibleFile: - msg = f"File '{rFile}' does not exist." - raise StreamingError(msg) - - return Response( - status=HTTPFound.status_code, - headers={ - "Location": f"{self.url}api/player/playlist/{quote(str(possibleFile).replace(download_path, '').strip('/'))}.m3u8" - }, - ) + async def make(self, file: Path) -> str: + ref = str(file).replace(self.download_path, "").strip("/") try: - ff = await ffprobe(rFile) + ff = await ffprobe(file) except UnicodeDecodeError: pass if "duration" not in ff.metadata: - msg = f"Unable to get '{rFile}' duration." + msg = f"Unable to get '{ref}' duration." raise StreamingError(msg) playlist = [] @@ -44,18 +30,18 @@ class Playlist: subs = "" duration: float = float(ff.metadata.get("duration")) - for sub_file in get_sidecar_subtitles(rFile): + for sub_file in get_sidecar_subtitles(file): lang = sub_file["lang"] item = sub_file["file"] name = sub_file["name"] subs = ',SUBTITLES="subs"' - url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(file).with_name(item.name)))}.m3u8?duration={duration}" + url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(ref).with_name(item.name)))}.m3u8?duration={duration}" playlist.append( f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"' ) playlist.append(f"#EXT-X-STREAM-INF:PROGRAM-ID=1{subs}") - playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8") + playlist.append(f"{self.url}api/player/m3u8/video/{quote(ref)}.m3u8") return "\n".join(playlist) diff --git a/app/library/Segments.py b/app/library/Segments.py index b45d894d..1e9c56e8 100644 --- a/app/library/Segments.py +++ b/app/library/Segments.py @@ -3,17 +3,19 @@ import hashlib import logging import os import tempfile +from pathlib import Path from .config import Config from .ffprobe import ffprobe -from .Utils import StreamingError, calc_download_path +from .Utils import StreamingError LOG = logging.getLogger("player.segments") class Segments: - def __init__(self, index: int, duration: float, vconvert: bool, aconvert: bool): + def __init__(self, download_path: str, index: int, duration: float, vconvert: bool, aconvert: bool): config = Config.get_instance() + self.download_path = download_path self.index = int(index) self.duration = float(duration) self.vconvert = bool(vconvert) @@ -24,23 +26,17 @@ class Segments: self.vconvert = True self.aconvert = True - async def stream(self, path: str, file: str) -> bytes: - realFile: str = calc_download_path(base_path=path, folder=file, create_path=False) - - if not os.path.exists(realFile): - msg = f"File {realFile} does not exist." - raise StreamingError(msg) - + async def stream(self, file: Path) -> bytes: try: - ff = await ffprobe(realFile) + ff = await ffprobe(file) except UnicodeDecodeError: pass tmpDir: str = tempfile.gettempdir() - tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.sha256(realFile.encode("utf-8")).hexdigest()}') + tmpFile = os.path.join(tmpDir, f'ytptube_stream.{hashlib.sha256(str(file).encode("utf-8")).hexdigest()}') if not os.path.exists(tmpFile): - os.symlink(realFile, tmpFile) + os.symlink(file, tmpFile) if self.index == 0: startTime: float = f"{0:.6f}" @@ -95,7 +91,7 @@ class Segments: fargs.append("mpegts") fargs.append("pipe:1") - LOG.debug(f"Streaming '{realFile}' segment '{self.index}'. ffmpeg: {' '.join(fargs)}") + LOG.debug(f"Streaming '{file}' segment '{self.index}'. ffmpeg: {' '.join(fargs)}") proc = await asyncio.subprocess.create_subprocess_exec( "ffmpeg", @@ -108,8 +104,8 @@ class Segments: data, err = await proc.communicate() if 0 != proc.returncode: - LOG.error(f"Failed to stream '{realFile}' segment '{self.index}'. {err.decode('utf-8')}.") - msg = f"Failed to stream '{realFile}' segment '{self.index}'." + LOG.error(f"Failed to stream '{file}' segment '{self.index}'. {err.decode('utf-8')}.") + msg = f"Failed to stream '{file}' segment '{self.index}'." raise StreamingError(msg) return data diff --git a/app/library/Subtitle.py b/app/library/Subtitle.py index 31742d45..495da32d 100644 --- a/app/library/Subtitle.py +++ b/app/library/Subtitle.py @@ -1,12 +1,12 @@ import logging -import pathlib +from pathlib import Path import anyio import pysubs2 from pysubs2.formats.substation import SubstationFormat from pysubs2.time import ms_to_times -from .Utils import calc_download_path +from .Utils import ALLOWED_SUBS_EXTENSIONS LOG = logging.getLogger("player.subtitle") @@ -22,33 +22,22 @@ SubstationFormat.ms_to_timestamp = ms_to_timestamp class Subtitle: - allowed_extensions: tuple[str] = ( - ".srt", - ".vtt", - ".ass", - ) + def __init__(self, download_path: str): + self.download_path = download_path - async def make(self, path: str, file: str) -> str: - realFile: str = calc_download_path(base_path=path, folder=file, create_path=False) - - rFile = pathlib.Path(realFile) - - if not rFile.exists(): - msg = f"File '{file}' does not exist." - raise Exception(msg) - - if rFile.suffix not in self.allowed_extensions: + async def make(self, file: Path) -> str: + if file.suffix not in ALLOWED_SUBS_EXTENSIONS: msg = f"File '{file}' subtitle type is not supported." raise Exception(msg) - if rFile.suffix == ".vtt": - async with await anyio.open_file(realFile) as f: + if file.suffix == ".vtt": + async with await anyio.open_file(file) as f: return await f.read() - subs = pysubs2.load(path=str(rFile)) + subs = pysubs2.load(path=str(file)) if len(subs.events) < 1: - msg = f"No subtitle events were found in '{rFile}'." + msg = f"No subtitle events were found in '{file}'." raise Exception(msg) if len(subs.events) < 2: diff --git a/app/library/Utils.py b/app/library/Utils.py index e2e472c2..fbe889a4 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -19,13 +19,7 @@ from .LogWrapper import LogWrapper LOG = logging.getLogger("Utils") -IGNORED_KEYS: tuple[str] = ( - "paths", - "outtmpl", - "progress_hooks", - "postprocessor_hooks", - "download_archive", -) +IGNORED_KEYS: tuple[str] = ("paths", "outtmpl", "progress_hooks", "postprocessor_hooks", "download_archive") YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") @@ -365,7 +359,7 @@ def check_id(file: pathlib.Path) -> bool | str: if id not in f.stem: continue - if f.suffix not in (".mp4", ".mkv", ".webm", ".m4v", ".m4a", ".mp3", ".aac", ".ogg"): + if f.suffix != file.suffix: continue return f.absolute() @@ -599,3 +593,31 @@ def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str: # Final fallback: Return generic binary type return "application/octet-stream" + + +def get_file(download_path: str, file: str | pathlib.Path) -> tuple[pathlib.Path, int]: + """ + Get the real file path. + + Args: + download_path (str): Base download path. + file (str|pathlib.Path): File path. + + Returns: + pathlib.Path: Real file path. + int: http status code. + + """ + file_path: str = os.path.normpath(os.path.join(download_path, str(file))) + if not file_path.startswith(download_path): + return (pathlib.Path(file_path), 404) + + realFile: str = pathlib.Path(calc_download_path(base_path=str(download_path), folder=str(file), create_path=False)) + if realFile.exists(): + return (realFile, 200) + + possibleFile = check_id(file=realFile) + if not possibleFile: + return (realFile, 404) + + return (pathlib.Path(possibleFile), 302)