diff --git a/API.md b/API.md index 37b5dc0d..d9c714d7 100644 --- a/API.md +++ b/API.md @@ -26,10 +26,6 @@ This document describes the available endpoints and their usage. All endpoints r - [GET /api/history](#get-apihistory) - [GET /api/tasks](#get-apitasks) - [PUT /api/tasks](#put-apitasks) - - [GET /api/workers](#get-apiworkers) - - [POST /api/workers](#post-apiworkers) - - [PATCH /api/workers/{id}](#patch-apiworkersid) - - [DELETE /api/workers/{id}](#delete-apiworkersid) - [GET /api/player/playlist/{file:.\*}.m3u8](#get-apiplayerplaylistfilem3u8) - [GET /api/player/m3u8/{mode}/{file:.\*}.m3u8](#get-apiplayerm3u8modefilem3u8) - [GET /api/player/segments/{segment}/{file:.\*}.ts](#get-apiplayersegmentssegmentfilets) @@ -391,86 +387,6 @@ or on error --- -### GET /api/workers -**Purpose**: Returns the status of the worker pool and all workers. - -**Response**: -```json -{ - "open": true|false, - "count": 4, - "workers": [ - { - "id": "worker-1", - "data": { "status": "downloading", ... } - }, - { - "id": "worker-2", - "data": { "status": "Waiting for download." } - }, - ... - ] -} -``` -- `open`: Indicates if there are any available workers. -- `count`: Total number of available workers. - ---- - -### POST /api/workers -**Purpose**: Restart the entire worker pool. - -**Response**: -```json -{ - "message": "Workers pool being restarted." -} -``` - ---- - -### PATCH /api/workers/{id} -**Purpose**: Restart a single worker by ID. - -**Path Parameter**: -- `id` = The worker ID. - -**Response**: -```json -{ - "status": "restarted" -} -``` -or -```json -{ - "status": "in_error_state" -} -``` - ---- - -### DELETE /api/workers/{id} -**Purpose**: Stop a single worker by ID. - -**Path Parameter**: -- `id` = The worker ID. - -**Response**: -```json -{ - "status": "stopped" -} -``` -or -```json -{ - "status": "in_error_state" -} -``` - ---- - ### GET /api/player/playlist/{file:.*}.m3u8 **Purpose**: Generate a playlist for a given local media file. diff --git a/app/library/HttpAPI.py b/app/library/HttpAPI.py index 101fd9a8..b7bf3f01 100644 --- a/app/library/HttpAPI.py +++ b/app/library/HttpAPI.py @@ -100,6 +100,7 @@ class HttpAPI(Common): self.routes = web.RouteTableDef() self.cache = Cache() self.app: web.Application | None = None + self.isRequestingBackground = False super().__init__(queue=self.queue, encoder=self.encoder, config=self.config) @@ -1259,109 +1260,6 @@ class HttpAPI(Common): return web.json_response(data=data, status=web.HTTPOk.status_code, dumps=self.encoder.encode) - @route("GET", "api/workers", "pool_list") - async def pool_list(self, _) -> Response: - """ - Get the workers status. - - Args: - _: The request object. - - Returns: - Response: The response object. - - """ - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - status = self.queue.pool.get_workers_status() - - data = [] - - for worker in status: - worker_status = status.get(worker) - data.append( - { - "id": worker, - "data": {"status": "Waiting for download."} if worker_status is None else worker_status, - } - ) - - return web.json_response( - data={ - "open": self.queue.pool.has_open_workers(), - "count": self.queue.pool.get_available_workers(), - "workers": data, - }, - status=web.HTTPOk.status_code, - dumps=lambda obj: json.dumps(obj, default=lambda o: f"<>"), - ) - - @route("POST", "api/workers", "pool_start") - async def pool_restart(self, _) -> Response: - """ - Restart the workers pool. - - Args: - _: The request object. - - Returns: - Response: The response object. - - """ - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - self.queue.pool.start() - - return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code) - - @route("PATCH", "api/workers/{id}", "worker_restart") - async def worker_restart(self, request: Request) -> Response: - """ - Restart a worker. - - Args: - request (Request): The request object. - - Returns: - Response: The response object - - """ - id: str = request.match_info.get("id") - if not id: - return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code) - - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - status = await self.queue.pool.restart(id, "requested by user.") - - return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code) - - @route("DELETE", "api/workers/{id}", "worker_stop") - async def worker_stop(self, request: Request) -> Response: - """ - Stop a worker. - - Args: - request (Request): The request object. - - Returns: - Response: The response object. - - """ - id: str = request.match_info.get("id") - if not id: - raise web.HTTPBadRequest(text="worker id is required.") - - if self.queue.pool is None: - return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) - - status = await self.queue.pool.stop(id, "requested by user.") - - return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code) - @route("GET", "api/player/playlist/{file:.*}.m3u8", "playlist") async def playlist(self, request: Request) -> Response: """ @@ -1399,7 +1297,9 @@ class HttpAPI(Common): 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=f"{base_path}/").make(file=realFile), + text=await Playlist(download_path=Path(self.config.download_path), url=f"{base_path}/").make( + file=realFile + ), headers={ "Content-Type": "application/x-mpegURL", "Cache-Control": "no-cache", @@ -1444,7 +1344,7 @@ class HttpAPI(Common): base_path: str = self.config.base_path.rstrip("/") try: - cls = M3u8(download_path=self.config.download_path, url=f"{base_path}/") + cls = M3u8(download_path=Path(self.config.download_path), url=f"{base_path}/") realFile, status = get_file(download_path=self.config.download_path, file=file) if web.HTTPFound.status_code == status: @@ -1708,7 +1608,11 @@ class HttpAPI(Common): """ backend = None + if self.isRequestingBackground: + return web.Response(status=web.HTTPTooManyRequests.status_code) + try: + self.isRequestingBackground = True backend = random.choice(self.config.pictures_backends) # noqa: S311 CACHE_KEY_BING = "random_background_bing" CACHE_KEY = "random_background" @@ -1776,6 +1680,8 @@ class HttpAPI(Common): await self.cache.aset(key=CACHE_KEY, value=data, ttl=3600) + LOG.debug(f"Random background image from '{backend!s}' cached.") + return web.Response( body=data.get("content"), headers={ @@ -1791,6 +1697,8 @@ class HttpAPI(Common): data={"error": "failed to retrieve the random background image."}, status=web.HTTPInternalServerError.status_code, ) + finally: + self.isRequestingBackground = False @route("GET", "api/file/ffprobe/{file:.*}", "ffprobe") async def get_ffprobe(self, request: Request) -> Response: @@ -2017,3 +1925,159 @@ class HttpAPI(Common): except OSError as e: LOG.exception(e) return web.json_response(data={"error": str(e)}, status=web.HTTPInternalServerError.status_code) + + @route("GET", "api/dev/loop") + async def debug_asyncio(self, _: Request) -> Response: + if not self.config.is_dev(): + return web.json_response( + data={"error": "This endpoint is only available in development mode."}, + status=web.HTTPForbidden.status_code, + ) + + import traceback + + tasks = [] + for task in asyncio.all_tasks(): + task_info = {"task": str(task), "stack": []} + for frame in task.get_stack(): + formatted = traceback.format_stack(f=frame) + task_info["stack"].extend(formatted) + + tasks.append(task_info) + + return web.json_response( + data={ + "total_tasks": len(tasks), + "loop": str(asyncio.get_event_loop()), + "tasks": tasks, + }, + status=web.HTTPOk.status_code, + dumps=self.encoder.encode, + ) + + @route("GET", "api/dev/workers", "pool_list") + async def pool_list(self, _) -> Response: + """ + Get the workers status. + + Args: + _: The request object. + + Returns: + Response: The response object. + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + status = self.queue.pool.get_workers_status() + + data = [] + + for worker in status: + worker_status = status.get(worker) + data.append( + { + "id": worker, + "data": {"status": "Waiting for download."} if worker_status is None else worker_status, + } + ) + + return web.json_response( + data={ + "open": self.queue.pool.has_open_workers(), + "count": self.queue.pool.get_available_workers(), + "workers": data, + }, + status=web.HTTPOk.status_code, + dumps=lambda obj: json.dumps(obj, default=lambda o: f"<>"), + ) + + @route("POST", "api/dev/workers", "pool_start") + async def pool_restart(self, _) -> Response: + """ + Restart the workers pool. + + Args: + _: The request object. + + Returns: + Response: The response object. + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + self.queue.pool.start() + + return web.json_response({"message": "Workers pool being restarted."}, status=web.HTTPOk.status_code) + + @route("PATCH", "api/dev/workers/{id}", "worker_restart") + async def worker_restart(self, request: Request) -> Response: + """ + Restart a worker. + + Args: + request (Request): The request object. + + Returns: + Response: The response object + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + worker_id: str = request.match_info.get("id") + if not worker_id: + return web.json_response({"error": "worker id is required."}, status=web.HTTPBadRequest.status_code) + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + status = await self.queue.pool.restart(worker_id, "requested by user.") + + return web.json_response({"status": "restarted" if status else "in_error_state"}, status=web.HTTPOk.status_code) + + @route("DELETE", "api/dev/workers/{id}", "worker_stop") + async def worker_stop(self, request: Request) -> Response: + """ + Stop a worker. + + Args: + request (Request): The request object. + + Returns: + Response: The response object. + + """ + if not self.config.is_dev(): + return web.json_response( + {"error": "This endpoint is only available in development mode."}, + status=web.HTTPNotFound.status_code, + ) + + worker_id: str = request.match_info.get("id") + if not worker_id: + raise web.HTTPBadRequest(text="worker id is required.") + + if self.queue.pool is None: + return web.json_response({"error": "Worker pool not initialized."}, status=web.HTTPNotFound.status_code) + + status = await self.queue.pool.stop(worker_id, "requested by user.") + + return web.json_response({"status": "stopped" if status else "in_error_state"}, status=web.HTTPOk.status_code) diff --git a/app/library/HttpSocket.py b/app/library/HttpSocket.py index 275a5b91..9724f905 100644 --- a/app/library/HttpSocket.py +++ b/app/library/HttpSocket.py @@ -51,7 +51,16 @@ class HttpSocket(Common): self._notify = EventBus.get_instance() # logger=True, engineio_logger=True, - self.sio = sio or socketio.AsyncServer(async_mode="aiohttp", cors_allowed_origins="*") + self.sio = sio or socketio.AsyncServer( + async_handlers=True, + async_mode="aiohttp", + cors_allowed_origins=[], + transports=["websocket"], + logger=self.config.debug, + engineio_logger=self.config.debug, + ping_interval=10, + ping_timeout=5, + ) encoder = encoder or Encoder() def emit(e: Event, _, **kwargs): @@ -77,6 +86,12 @@ class HttpSocket(Common): async def on_shutdown(self, _: web.Application): LOG.debug("Shutting down socket server.") + for sid in self.sio.manager.get_participants("/", None): + LOG.debug(f"Disconnecting client '{sid}'.") + await self.sio.disconnect(sid[0], namespace="/") + + LOG.debug("Socket server shutdown complete.") + def attach(self, app: web.Application): self.sio.attach(app, socketio_path=f"{self.config.base_path.rstrip('/')}/socket.io") diff --git a/app/library/M3u8.py b/app/library/M3u8.py index a6b35b2c..3bbe3c00 100644 --- a/app/library/M3u8.py +++ b/app/library/M3u8.py @@ -12,9 +12,9 @@ class M3u8: ok_vcodecs: tuple = ("h264", "x264", "avc") ok_acodecs: tuple = ("aac", "m4a", "mp3") - def __init__(self, download_path: str, url: str, segment_duration: float | None = None): + def __init__(self, download_path: Path, url: str, segment_duration: float | None = None): self.url = url - self.download_path = download_path + self.download_path: Path = download_path self.duration = float(segment_duration) if segment_duration is not None else self.duration async def make_stream(self, file: Path) -> str: @@ -23,7 +23,7 @@ class M3u8: except UnicodeDecodeError: pass - file = str(file).replace(self.download_path, "").strip("/") + urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/") if "duration" not in ff.metadata: error = f"Unable to get '{file}' play duration." @@ -31,7 +31,7 @@ class M3u8: duration: float = float(ff.metadata.get("duration")) - m3u8 = [] + m3u8: list = [] m3u8.append("#EXTM3U") m3u8.append("#EXT-X-VERSION:3") @@ -57,7 +57,7 @@ class M3u8: m3u8.append(f"#EXTINF:{segmentSize},") - url = f"{self.url}api/player/segments/{i}/{quote(file)}.ts" + url = f"{self.url}api/player/segments/{i}/{quote(urlPath)}.ts" if len(segmentParams) > 0: url += "?" + "&".join([f"{key}={value}" for key, value in segmentParams.items()]) @@ -70,13 +70,15 @@ class M3u8: async def make_subtitle(self, file: Path, duration: float) -> str: m3u8 = [] + urlPath: str = str(file.relative_to(self.download_path).as_posix()).strip("/") + m3u8.append("#EXTM3U") m3u8.append("#EXT-X-VERSION:3") m3u8.append(f"#EXT-X-TARGETDURATION:{int(self.duration)}") 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(str(file).replace(self.download_path, '').strip('/'))}.vtt") + m3u8.append(f"{self.url}api/player/subtitle/{quote(urlPath)}.vtt") m3u8.append("#EXT-X-ENDLIST") return "\n".join(m3u8) diff --git a/app/library/PackageInstaller.py b/app/library/PackageInstaller.py index 570a0509..2052f896 100644 --- a/app/library/PackageInstaller.py +++ b/app/library/PackageInstaller.py @@ -3,6 +3,7 @@ import logging import os import subprocess import sys +from pathlib import Path LOG = logging.getLogger("package_installer") @@ -12,11 +13,15 @@ class Packages: from_env = env.split() if env else [] from_file = [] - if os.path.exists(file) and os.access(file, os.R_OK): - with open(file) as f: - from_file = [pkg.strip() for pkg in f if pkg.strip()] + if file: + file = Path(file) + if file.exists() and os.access(str(file), os.R_OK): + with open(file) as f: + from_file: list[str] = [pkg.strip() for pkg in f if pkg.strip()] + else: + LOG.error(f"pip packages file '{file}' doesn't exist or is not readable.") - self.packages: list = list(set(from_env + from_file)) + self.packages: list[str] = list(set(from_env + from_file)) self.upgrade = bool(upgrade) def has_packages(self) -> bool: diff --git a/app/library/Playlist.py b/app/library/Playlist.py index 563956e1..af648ebb 100644 --- a/app/library/Playlist.py +++ b/app/library/Playlist.py @@ -8,12 +8,12 @@ from .Utils import StreamingError, get_file_sidecar class Playlist: _url: str = None - def __init__(self, download_path: str, url: str): - self.url = url - self.download_path = download_path + def __init__(self, download_path: Path, url: str): + self.url: str = url + self.download_path: Path = download_path async def make(self, file: Path) -> str: - ref = str(file).replace(self.download_path, "").strip("/") + ref: str = Path(str(file.relative_to(self.download_path)).strip("/")) try: ff = await ffprobe(file) @@ -24,19 +24,19 @@ class Playlist: msg = f"Unable to get '{ref}' duration." raise StreamingError(msg) - playlist = [] + playlist: list[str] = [] playlist.append("#EXTM3U") - subs = "" + subs: str = "" duration: float = float(ff.metadata.get("duration")) for sub_file in get_file_sidecar(file).get("subtitle", []): - lang = sub_file["lang"] - item = Path(sub_file["file"]) - name = sub_file["name"] + lang: str = sub_file["lang"] + item: Path = sub_file["file"] + name: str = sub_file["name"] subs = ',SUBTITLES="subs"' - url = f"{self.url}api/player/m3u8/subtitle/{quote(str(Path(ref).with_name(item.name)))}.m3u8?duration={duration}" + url = f"{self.url}api/player/m3u8/subtitle/{quote(str(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}"' ) diff --git a/app/library/Presets.py b/app/library/Presets.py index ee695ea4..dbb044ff 100644 --- a/app/library/Presets.py +++ b/app/library/Presets.py @@ -1,8 +1,8 @@ import json import logging -import os import uuid from dataclasses import dataclass, field +from pathlib import Path from typing import Any from aiohttp import web @@ -11,10 +11,51 @@ from .config import Config from .encoder import Encoder from .Events import EventBus, Events from .Singleton import Singleton -from .Utils import arg_converter, clean_item +from .Utils import arg_converter, init_class LOG = logging.getLogger("presets") +DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [ + { + "id": "3e163c6c-64eb-4448-924f-814b629b3810", + "name": "default", + "default": True, + }, + { + "id": "5bf9c42b-8852-468a-99f5-915622dfba25", + "name": "Best video and audio", + "cli": "--format 'bv+ba/b'", + "default": True, + }, + { + "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b", + "name": "1080p H264/m4a or best available", + "cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", + "default": True, + }, + { + "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e", + "name": "720p h264/m4a or best available", + "cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", + "default": True, + }, + { + "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", + "name": "Audio only", + "cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", + "default": True, + }, + { + "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60", + "name": "yt-dlp info reader plugin", + "description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary', + "folder": "youtube", + "template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s", + "cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", + "default": True, + }, +] + @dataclass(kw_only=True) class Preset: @@ -65,28 +106,26 @@ class Presets(metaclass=Singleton): _default: list[Preset] = [] - def __init__(self, file: str | None = None, config: Config | None = None): + def __init__(self, file: str | Path | 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._file: Path = Path(file) if file else Path(config.config_path).joinpath("presets.json") - if os.path.exists(self._file) and "600" != oct(os.stat(self._file).st_mode)[-3:]: + if self._file.exists() and "600" != self._file.stat().st_mode: try: - os.chmod(self._file, 0o600) + self._file.chmod(0o600) except Exception: pass - default_file = os.path.join(os.path.dirname(__file__), "presets.json") - with open(default_file) as f: - for i, preset in enumerate(json.load(f)): - try: - self.validate(preset) - self._default.append(Preset(**preset)) - except Exception as e: - LOG.error(f"Failed to parse '{default_file}:{i}'. '{e!s}'.") - continue + for i, preset in enumerate(DEFAULT_PRESETS): + try: + self.validate(preset) + self._default.append(init_class(Preset, preset)) + except Exception as e: + LOG.error(f"Failed to parse default preset ':{i}'. '{e!s}'.") + continue def event_handler(_, __): msg = "Not implemented" @@ -138,13 +177,12 @@ class Presets(metaclass=Singleton): """ self.clear() - if not os.path.exists(self._file) or os.path.getsize(self._file) < 10: + if not self._file.exists() or self._file.stat().st_size < 10: return self - LOG.info(f"Loading '{self._file}'.") try: - with open(self._file) as f: - presets = json.load(f) + LOG.info(f"Loading '{self._file}'.") + presets: dict = json.loads(self._file.read_text()) except Exception as e: LOG.error(f"Failed to parse '{self._file}'. '{e}'.") return self @@ -160,7 +198,6 @@ class Presets(metaclass=Singleton): preset["id"] = str(uuid.uuid4()) need_save = True - preset, preset_status = clean_item(preset, keys=("args", "postprocessors")) if preset.get("format"): if not preset.get("cli"): preset.update({"cli": f"--format {preset['format']}"}) @@ -172,10 +209,7 @@ class Presets(metaclass=Singleton): preset.pop("format") need_save = True - preset = Preset(**preset) - - if preset_status: - need_save = True + preset: Preset = init_class(Preset, preset) self._items.append(preset) except Exception as e: @@ -183,7 +217,7 @@ class Presets(metaclass=Singleton): continue if need_save: - LOG.info(f"Saving '{self._file}' due to changes.") + LOG.info(f"Saving '{self._file}'.") self.save(self._items) return self @@ -255,7 +289,7 @@ class Presets(metaclass=Singleton): for i, preset in enumerate(items): try: if not isinstance(preset, Preset): - preset = Preset(**preset) + preset: Preset = init_class(Preset, preset) items[i] = preset except Exception as e: LOG.error(f"Failed to save item '{i}' due to parsing error. '{e!s}'.") @@ -268,8 +302,9 @@ class Presets(metaclass=Singleton): continue try: - with open(self._file, "w") as f: - json.dump(obj=[preset.serialize() for preset in items if preset.default is False], fp=f, indent=4) + self._file.write_text( + json.dumps(obj=[preset.serialize() for preset in items if preset.default is False], indent=4) + ) LOG.info(f"Saved '{self._file}'.") except Exception as e: diff --git a/app/library/Utils.py b/app/library/Utils.py index 98d68ba8..66366830 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -13,6 +13,7 @@ from datetime import UTC, datetime, timedelta from functools import lru_cache from http.cookiejar import MozillaCookieJar from pathlib import Path +from typing import TypeVar import yt_dlp from Crypto.Cipher import AES @@ -60,6 +61,8 @@ FILES_TYPE: list = [ DATETIME_PATTERN = re.compile(r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:\d{2}))\s?") +T = TypeVar("T") + class StreamingError(Exception): """Raised when an error occurs during streaming.""" @@ -1180,7 +1183,7 @@ def delete_dir(dir: Path) -> bool: return False -def init_class(cls: type, data: dict): +def init_class(cls: type[T], data: dict) -> T: """ Initialize a class instance with data from a dictionary, filtering out keys not present in the class fields. @@ -1189,7 +1192,7 @@ def init_class(cls: type, data: dict): data (dict): The data to use for initialization. Returns: - object: An instance of the class initialized with the provided data. + T: An instance of the class initialized with the provided data. """ from dataclasses import fields diff --git a/app/library/config.py b/app/library/config.py index 1753c696..bf3ab7da 100644 --- a/app/library/config.py +++ b/app/library/config.py @@ -16,6 +16,9 @@ from .version import APP_VERSION class Config: + app_env: str = "production" + """The application environment, can be 'production' or 'development'.""" + config_path: str = "." """The path to the configuration directory.""" @@ -228,6 +231,7 @@ class Config: "file_logging", "base_path", "is_native", + "app_env", ) "The variables that are relevant to the frontend." @@ -416,6 +420,13 @@ class Config: logging.getLogger("httpx").setLevel(logging.WARNING) logging.getLogger("httpcore").setLevel(logging.INFO) + # check env + if self.app_env not in ("production", "development"): + msg: str = ( + f"Invalid application environment '{self.app_env}' specified. Must be 'production' or 'development'." + ) + raise ValueError(msg) + if self.version == "dev-master": self._version_via_git() @@ -433,6 +444,26 @@ class Config: return attrs + def is_dev(self) -> bool: + """ + Check if the application is running in development mode. + + Returns: + bool: True if the application is in development mode, False otherwise. + + """ + return "development" == self.app_env + + def is_prod(self) -> bool: + """ + Check if the application is running in production mode. + + Returns: + bool: True if the application is in production mode, False otherwise. + + """ + return "production" == self.app_env + def get_ytdlp_args(self) -> dict: try: return arg_converter(args=self._ytdlp_cli_mutable, level=True) diff --git a/app/library/presets.json b/app/library/presets.json deleted file mode 100644 index caf82fcf..00000000 --- a/app/library/presets.json +++ /dev/null @@ -1,62 +0,0 @@ -[ - { - "id": "3e163c6c-64eb-4448-924f-814b629b3810", - "name": "default", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "", - "default": true - }, - { - "id": "5bf9c42b-8852-468a-99f5-915622dfba25", - "name": "Best video and audio", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "--format 'bv+ba/b'", - "default": true - }, - { - "id": "441675ed-b739-40f0-a0b0-1ecfcb9dc48b", - "name": "1080p H264/m4a or best available", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "-S vcodec:h264 --format 'bv[height<=1080][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", - "default": true - }, - { - "id": "9719fcc3-4cf2-4d88-b1e4-74dff3dba00e", - "name": "720p h264/m4a or best available", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "-S vcodec:h264 --format 'bv[height<=720][ext=mp4]+ba[ext=m4a]/b[ext=mp4]/b[ext=webm]'", - "default": true - }, - { - "id": "a6fd4b25-2b3e-458d-bb57-b75e41cc4330", - "name": "Audio only", - "description": "", - "folder": "", - "template": "", - "cookies": "", - "cli": "--extract-audio --add-chapters --embed-metadata --embed-thumbnail --format 'bestaudio/best'", - "default": true - }, - { - "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60", - "name": "ytdlp-info-reader", - "description": "This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o \"%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s\" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary", - "folder": "youtube", - "template": "%(channel)s %(channel_id|Unknown_id)s/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s", - "cookies": "", - "cli": "--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata", - "default": true - } -] diff --git a/app/main.py b/app/main.py index f184e6cc..1b646740 100644 --- a/app/main.py +++ b/app/main.py @@ -31,6 +31,11 @@ class Main: self._config = Config.get_instance(is_native=is_native) self._app = web.Application() + if self._config.debug: + loop = asyncio.get_event_loop() + loop.set_debug(True) + loop.slow_callback_duration = 0.05 + self._check_folders() caribou.upgrade(self._config.db_file, ROOT_PATH / "migrations") diff --git a/ui/stores/SocketStore.js b/ui/stores/SocketStore.js index 18af07d4..e4c6cf99 100644 --- a/ui/stores/SocketStore.js +++ b/ui/stores/SocketStore.js @@ -11,7 +11,10 @@ export const useSocketStore = defineStore('socket', () => { const isConnected = ref(false) const connect = () => { - let opts = { withCredentials: true } + let opts = { + transports: ['websocket'], + withCredentials: true, + } let url = runtimeConfig.public.wss