diff --git a/.vscode/launch.json b/.vscode/launch.json index 96b9c850..e2486ea9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,6 @@ "runtimeArgs": [ "run", "dev", - "--", "--port", "8082" ], @@ -21,7 +20,8 @@ "NUXT_API_URL": "http://localhost:8081/api/", "NUXT_PUBLIC_WSS": ":8081/", }, - "console": "internalConsole" + "console": "internalConsole", + "outputCapture": "std", }, { "name": "Python: main.py", diff --git a/.vscode/settings.json b/.vscode/settings.json index f21e8522..ac6857c9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -23,13 +23,16 @@ "httpx", "libcurl", "libx", + "matroska", "mpegts", + "msvideo", "muxdelay", "nodesc", "noprogress", "postprocessor", "preferredcodec", "preferredquality", + "quicktime", "tmpfilename", "vcodec", "vconvert", diff --git a/app/library/DownloadQueue.py b/app/library/DownloadQueue.py index 10079d4e..fc718055 100644 --- a/app/library/DownloadQueue.py +++ b/app/library/DownloadQueue.py @@ -395,7 +395,7 @@ class DownloadQueue(metaclass=Singleton): "func": lambda _, msg: logs.append(msg), "level": logging.WARNING, }, - **merge_config(self.config.ytdl_options, config), + **get_opts(preset, merge_config(self.config.ytdl_options, config)), } if cookies: @@ -408,7 +408,7 @@ class DownloadQueue(metaclass=Singleton): entry = await asyncio.wait_for( fut=asyncio.get_running_loop().run_in_executor( - None, extract_info, get_opts(preset, yt_conf), url, bool(self.config.ytdl_debug) + None, extract_info, yt_conf, url, bool(self.config.ytdl_debug) ), timeout=self.config.extract_info_timeout, ) diff --git a/app/library/EventsSubscriber.py b/app/library/EventsSubscriber.py index 2fb5a683..45d8b659 100644 --- a/app/library/EventsSubscriber.py +++ b/app/library/EventsSubscriber.py @@ -41,6 +41,8 @@ class Events: TASK_FINISHED = "task_finished" TASK_ERROR = "task_error" + PRESETS_ADD = "preset_add" + @dataclass(kw_only=True) class Event: diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index e49453da..5f276f45 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -29,10 +29,21 @@ from .ffprobe import ffprobe from .M3u8 import M3u8 from .Notifications import Notification, NotificationEvents from .Playlist import Playlist +from .Presets import Presets from .Segments import Segments from .Subtitle import Subtitle from .Tasks import Task, Tasks -from .Utils import StreamingError, arg_converter, calc_download_path, get_video_info, validate_url, validate_uuid +from .Utils import ( + IGNORED_KEYS, + StreamingError, + arg_converter, + calc_download_path, + get_mime_type, + get_sidecar_subtitles, + get_video_info, + validate_url, + validate_uuid, +) LOG = logging.getLogger("http_api") MIME = magic.Magic(mime=True) @@ -108,6 +119,7 @@ class HttpAPI(Common): HttpAPI: The instance of the HttpAPI. """ + app.middlewares.append(HttpAPI.middle_wares()) if self.config.auth_username and self.config.auth_password: app.middlewares.append(HttpAPI.basic_auth(self.config.auth_username, self.config.auth_password)) @@ -299,6 +311,24 @@ class HttpAPI(Common): return middleware_handler + @staticmethod + def middle_wares() -> Awaitable: + @web.middleware + async def middleware_handler(request: Request, handler: RequestHandler) -> Response: + response = await handler(request) + + if isinstance(response, web.FileResponse): + try: + ff_info = await ffprobe(response._path) + mime_type = get_mime_type(ff_info.get("metadata", {}), response._path) + response.content_type = mime_type + except Exception: + pass + + return response + + return middleware_handler + @route("OPTIONS", "/{path:.*}") async def add_coors(self, _: Request) -> Response: """ @@ -347,11 +377,28 @@ class HttpAPI(Common): return web.json_response(data={"error": "args param is required."}, status=web.HTTPBadRequest.status_code) try: - return web.json_response(data=arg_converter(args), status=web.HTTPOk.status_code) + response = {"opts": {}, "output_template": None, "download_path": None} + + data = arg_converter(args) + + if "outtmpl" in data and "default" in data["outtmpl"]: + response["output_template"] = data["outtmpl"]["default"] + + if "paths" in data and "home" in data["paths"]: + response["download_path"] = data["paths"]["home"] + + for key in data: + if key in IGNORED_KEYS: + continue + if not key.startswith("_"): + response["opts"][key] = data[key] + + return web.json_response(data=response, status=web.HTTPOk.status_code) except Exception as e: err = str(e).strip() err = err.split("\n")[-1] if "\n" in err else err LOG.error(f"Failed to convert args. '{err}'.") + LOG.exception(e) return web.json_response( data={"error": f"Failed to convert args. '{err}'."}, status=web.HTTPBadRequest.status_code ) @@ -553,6 +600,22 @@ class HttpAPI(Common): dumps=self.encoder.encode, ) + @route("GET", "api/presets") + async def presets(self, _: Request) -> Response: + """ + Get the presets. + + Args: + _: The request object. + + Returns: + Response: The response object. + + """ + return web.json_response( + data=Presets.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=self.encoder.encode + ) + @route("GET", "api/tasks") async def tasks(self, _: Request) -> Response: """ @@ -1136,6 +1199,49 @@ class HttpAPI(Common): except Exception as e: return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + @route("GET", "api/file/info/{file:.*}") + async def get_file_info(self, request: Request) -> Response: + """ + Get file info + + Args: + request (Request): The request object. + + 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) + + 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 = Path(realFile) + + ff_info = await ffprobe(realFile) + + response = { + "ffprobe": ff_info, + "mimetype": get_mime_type(ff_info.get("metadata", {}), realFile), + "sidecar": get_sidecar_subtitles(realFile), + } + + for i, f in enumerate(response["sidecar"]): + response["sidecar"][i]["file"] = ( + str(Path(realFile).with_name(f["file"].name)).replace(self.config.download_path, "").strip("/") + ) + + return web.json_response(data=response, status=web.HTTPOk.status_code, dumps=self.encoder.encode) + except Exception as e: + LOG.exception(e) + return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + @route("GET", "api/youtube/auth") async def is_authenticated(self, request: Request) -> Response: """ diff --git a/app/library/Playlist.py b/app/library/Playlist.py index a83074ca..befd9193 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -1,13 +1,10 @@ -import glob -import re from pathlib import Path from urllib.parse import quote from aiohttp.web import HTTPFound, Response from .ffprobe import ffprobe -from .Subtitle import Subtitle -from .Utils import StreamingError, calc_download_path, check_id +from .Utils import StreamingError, calc_download_path, check_id, get_sidecar_subtitles class Playlist: @@ -41,28 +38,19 @@ class Playlist: msg = f"Unable to get '{rFile}' duration." raise StreamingError(msg) - duration: float = float(ff.metadata.get("duration")) - playlist = [] playlist.append("#EXTM3U") subs = "" - index = 0 - for item in self.get_sidecar_files(rFile): - if item.suffix not in Subtitle.allowed_extensions: - continue - - index += 1 - lang: str = "und" - lg = re.search(r"\.(?P\w{2,3})\.\w{3}$", item.name) - if lg: - lang = lg.groupdict().get("lang") + duration: float = float(ff.metadata.get("duration")) + for sub_file in get_sidecar_subtitles(rFile): + 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}" - name = f"{item.suffix[1:].upper()} ({index}) - {lang}" playlist.append( f'#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="{name}",DEFAULT=NO,AUTOSELECT=NO,FORCED=NO,LANGUAGE="{lang}",URI="{url}"' ) @@ -71,20 +59,3 @@ class Playlist: playlist.append(f"{self.url}api/player/m3u8/video/{quote(file)}.m3u8") return "\n".join(playlist) - - def get_sidecar_files(self, file: Path) -> list[Path]: - """ - Get sidecar files for the given file. - - :param file: File to get sidecar files for. - :return: List of sidecar files. - """ - files = [] - - for sub_file in file.parent.glob(f"{glob.escape(file.stem)}.*"): - if sub_file == file or sub_file.is_file() is False or sub_file.stem.startswith("."): - continue - - files.append(sub_file) - - return files diff --git a/app/library/Presets.py b/app/library/Presets.py new file mode 100644 index 00000000..3e850b6c --- /dev/null +++ b/app/library/Presets.py @@ -0,0 +1,234 @@ +import asyncio +import json +import logging +import os +from dataclasses import dataclass, field +from typing import Any + +from aiohttp import web + +from .config import Config +from .Emitter import Emitter +from .encoder import Encoder +from .EventsSubscriber import Event, Events, EventsSubscriber +from .Singleton import Singleton + +LOG = logging.getLogger("presets") + + +@dataclass(kw_only=True) +class Preset: + name: str + """The name of the preset.""" + + format: str + """The format of the preset.""" + + args: dict[str, list[str] | bool] | None = field(default_factory=dict) + """The arguments of the preset.""" + + postprocessors: list | None = field(default_factory=list) + """The postprocessors of the preset.""" + + def serialize(self) -> dict: + return self.__dict__ + + def json(self) -> str: + return Encoder().encode(self.serialize()) + + def get(self, key: str, default: Any = None) -> Any: + return self.serialize().get(key, default) + + +class Presets(metaclass=Singleton): + """ + This class is used to manage the presets. + """ + + _presets: list[Preset] = [] + """The list of presets.""" + + _instance = None + """The instance of the class.""" + + def __init__( + self, + file: str | None = None, + emitter: Emitter | None = None, + loop: asyncio.AbstractEventLoop | None = None, + config: Config | None = None, + ): + Presets._instance = self + + config = config or Config.get_instance() + + self._file: str = file or os.path.join(config.config_path, "presets.json") + self._loop: asyncio.AbstractEventLoop = loop or asyncio.get_event_loop() + self._emitter: Emitter = emitter or Emitter.get_instance() + + if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: + try: + os.chmod(self._file, 0o600) + except Exception: + pass + + def handle_event(_, e: Event): + self.save(**e.data) + + EventsSubscriber.get_instance().subscribe(Events.PRESETS_ADD, f"{__class__}.save", handle_event) + + @staticmethod + def get_instance() -> "Presets": + """ + Get the instance of the class. + + Returns: + Presets: The instance of the class + + """ + if not Presets._instance: + Presets._instance = Presets() + + return Presets._instance + + async def on_shutdown(self, _: web.Application): + pass + + def attach(self, _: web.Application): + """ + Attach the work to the aiohttp application. + + Args: + _ (web.Application): The aiohttp application. + + Returns: + None + + """ + self.load() + + def get_all(self) -> list[Preset]: + """Return the presets.""" + return self._presets + + def load(self) -> "Presets": + """ + Load the Presets. + + Returns: + Presets: The current instance. + + """ + self.clear() + + if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + return self + + LOG.info(f"Loading presets from '{self._file}'.") + try: + with open(self._file) as f: + presets = json.load(f) + except Exception as e: + LOG.error(f"Failed to parse presets from '{self._file}'. '{e}'.") + return self + + if not presets or len(presets) < 1: + LOG.info(f"No presets were defined in '{self._file}'.") + return self + + for i, preset in enumerate(presets): + try: + preset = Preset(**preset) + self._presets.append(preset) + except Exception as e: + LOG.error(f"Failed to parse preset at list position '{i}'. '{e!s}'.") + continue + + return self + + def clear(self) -> "Presets": + """ + Clear all presets + + Returns: + Presets: The current instance. + + """ + if len(self._presets) < 1: + return self + + self._presets.clear() + + return self + + def validate(self, preset: Preset | dict) -> bool: + """ + Validate the preset. + + Args: + preset (Preset|dict): The preset to validate. + + Returns: + bool: True if the preset is valid, False otherwise. + + """ + if not isinstance(preset, dict): + if not isinstance(preset, Preset): + msg = "Invalid preset type." + raise ValueError(msg) # noqa: TRY004 + + preset = preset.serialize() + + if not preset.get("name"): + msg = "No name found." + raise ValueError(msg) + + if not preset.get("format"): + msg = "No format found." + raise ValueError(msg) + + if preset.get("args") and not isinstance(preset.get("args"), dict): + msg = "Invalid args type. expected dict." + raise ValueError(msg) + + if preset.get("postprocessors") and not isinstance(preset.get("postprocessors"), list): + msg = "Invalid postprocessors type. expected list." + raise ValueError(msg) + + return True + + def save(self, presets: list[Preset | dict]) -> "Presets": + """ + Save the presets. + + Args: + presets (list[Preset]): The presets to save. + + Returns: + Presets: The current instance. + + """ + for i, preset in enumerate(presets): + try: + if not isinstance(preset, Preset): + preset = Preset(**preset) + presets[i] = preset + except Exception as e: + LOG.error(f"Failed to save preset '{i}' due to parsing error. '{e!s}'.") + continue + + try: + self.validate(preset) + except ValueError as e: + LOG.error(f"Failed to validate preset '{i}: {preset.name}'. '{e}'.") + continue + + try: + with open(self._file, "w") as f: + json.dump(obj=[preset.serialize() for preset in presets], fp=f, indent=4) + + LOG.info(f"Presets saved to '{self._file}'.") + except Exception as e: + LOG.error(f"Failed to save presets to '{self._file}'. '{e!s}'.") + + return self diff --git a/app/library/Utils.py b/app/library/Utils.py index 078ec1c3..e2e472c2 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -1,4 +1,5 @@ import copy +import glob import ipaddress import json import logging @@ -23,11 +24,12 @@ IGNORED_KEYS: tuple[str] = ( "outtmpl", "progress_hooks", "postprocessor_hooks", - "format", "download_archive", ) YTDLP_INFO_CLS: yt_dlp.YoutubeDL = None +ALLOWED_SUBS_EXTENSIONS: tuple[str] = (".srt", ".vtt", ".ass") + class StreamingError(Exception): """Raised when an error occurs during streaming.""" @@ -45,6 +47,11 @@ def get_opts(preset: str, ytdl_opts: dict) -> dict: ytdl extra options """ + if "format" in ytdl_opts and len(ytdl_opts["format"]) > 2: + format = ytdl_opts["format"] + LOG.info(f"Format '{format}' was given via yt-dlp options. Therefore, the preset will be ignored.") + return ytdl_opts + opts = copy.deepcopy(ytdl_opts) if "default" == preset: @@ -520,3 +527,75 @@ def validate_uuid(uuid_str: str, version: int = 4) -> bool: return True except ValueError: return False + + +def get_sidecar_subtitles(file: pathlib.Path) -> list[dict]: + """ + Get sidecar files for the given file. + + :param file: File to get sidecar files for. + :return: List of sidecar files. + """ + files = [] + + for i, f in enumerate(file.parent.glob(f"{glob.escape(file.stem)}.*")): + if f == file or f.is_file() is False or f.stem.startswith("."): + continue + + if f.suffix not in ALLOWED_SUBS_EXTENSIONS: + continue + + if f.stat().st_size < 1: + continue + + lg = re.search(r"\.(?P\w{2,3})\.\w{3}$", f.name) + lang = lg.groupdict().get("lang") if lg else "und" + + files.append({"file": f, "lang": lang, "name": f"{f.suffix[1:].upper()} ({i}) - {lang}"}) + + return files + + +def get_mime_type(metadata: dict, file_path: pathlib.Path) -> str: + """ + Determine the correct MIME type for a video file based on ffprobe metadata. + + Args: + metadata (dict): Parsed JSON output from ffprobe. + file_path (str): The path to the video file for fallback detection. + + Returns: + str: MIME type compatible with HTML5