diff --git a/.vscode/launch.json b/.vscode/launch.json index a0cd2cce..14415dad 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,4 @@ { - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { @@ -18,17 +15,17 @@ "cwd": "${workspaceFolder}/ui", "env": { "NUXT_API_URL": "http://localhost:8081/api/", - "NUXT_PUBLIC_WSS": ":8081/", + "NUXT_PUBLIC_WSS": ":8081/" }, "console": "internalConsole", - "outputCapture": "std", + "outputCapture": "std" }, { - "name": "Python: main.py", + "name": "Python: main.py ", "type": "debugpy", "request": "launch", "program": "app/main.py", - "console": "internalConsole", + "console": "integratedTerminal", "justMyCode": true, "env": { "YTP_CONFIG_PATH": "${workspaceFolder}/var/config", @@ -36,7 +33,9 @@ "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", "PYDEVD_DISABLE_FILE_VALIDATION": "1", "YTP_IGNORE_UI": "true" - } + }, + "subProcess": true, + "postDebugTask": "kill-debugpy" }, { "name": "Node: Generate UI", @@ -65,14 +64,14 @@ "YTP_CONFIG_PATH": "${workspaceFolder}/var/config", "YTP_DOWNLOAD_PATH": "${workspaceFolder}/var/downloads", "YTP_TEMP_PATH": "${workspaceFolder}/var/tmp", - "YTP_LOG_LEVEL": "DEBUG", + "YTP_LOG_LEVEL": "DEBUG" } }, { "name": "Python: Attach To Process", "type": "debugpy", "request": "attach", - "processId": "${command:pickProcess}", + "processId": "${command:pickProcess}" }, { "name": "Python: Attach To Container", diff --git a/.vscode/settings.json b/.vscode/settings.json index 9211c3a5..29dfa3b4 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -111,7 +111,7 @@ "youtu" ], "css.styleSheets": [ - "ui/assets/css/*.css" + "ui/app/assets/css/*.css" ], "spellright.language": [ "en" diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 00000000..ee6cc98f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,22 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "kill-debugpy", + "type": "shell", + "command": "pkill -f debugpy || true", + "problemMatcher": [], + "presentation": { + "echo": false, + "reveal": "never", + "focus": false, + "panel": "dedicated", + "showReuseMessage": false, + "clear": false + }, + "runOptions": { + "runOn": "folderOpen" + } + } + ] +} diff --git a/API.md b/API.md index b761dd36..84588a36 100644 --- a/API.md +++ b/API.md @@ -142,8 +142,9 @@ or an error: **Query Parameters**: - `url=` (required) -- `preset=` (optional) - The preset to use for extracting info -- `force=true` (optional) - Force fetch new info instead of using cache +- `preset=` (optional) - The preset to use for extracting info. +- `force=true` (optional) - Force fetch new info instead of using cache. +- `args=` (optional) - The yt-dlp command options to apply to the info extraction. **Response** (example): ```json @@ -153,6 +154,8 @@ or an error: "extractor": "youtube", "_cached": { "status": "miss|hit", + "preset": "", + "cli_args": "", "key": "", "ttl": 300, "ttl_left": 299.82, diff --git a/app/library/Scheduler.py b/app/library/Scheduler.py index 4b03b509..fd7c425b 100644 --- a/app/library/Scheduler.py +++ b/app/library/Scheduler.py @@ -58,6 +58,7 @@ class Scheduler(metaclass=Singleton): for job in self._jobs: try: self._jobs[job].stop() + LOG.debug(f"Stopped job '{job}'.") except Exception as e: LOG.exception(e) LOG.error(f"Failed to stop job '{job}'. Error message '{e!s}'.") diff --git a/app/library/Tasks.py b/app/library/Tasks.py index 5e0d54c2..256283a0 100644 --- a/app/library/Tasks.py +++ b/app/library/Tasks.py @@ -35,6 +35,7 @@ class Task: template: str = "" cli: str = "" auto_start: bool = True + handler_enabled: bool = True def serialize(self) -> dict: return self.__dict__ @@ -100,7 +101,8 @@ class Tasks(metaclass=Singleton): return Tasks._instance async def on_shutdown(self, _: web.Application): - pass + self.clear(shutdown=True) + self._task_handler.on_shutdown(_) def attach(self, _: web.Application): """ @@ -116,6 +118,7 @@ class Tasks(metaclass=Singleton): lambda data, _, **kwargs: self.save(**data.data), # noqa: ARG005 f"{__class__.__name__}.add", ) + self._task_handler.load() def get_all(self) -> list[Task]: """Return the tasks.""" @@ -155,7 +158,7 @@ class Tasks(metaclass=Singleton): self._tasks.append(task) - if not task.timer or "[only_handler]" in task.name: + if not task.timer: continue try: @@ -361,12 +364,21 @@ class Tasks(metaclass=Singleton): class HandleTask: _tasks: Tasks + _handlers: list[type] + _scheduler: Scheduler + _config: Config + _task_name: str def __init__(self, scheduler: Scheduler, tasks: Tasks, config: Config) -> None: self._tasks = tasks + self._scheduler = scheduler + self._config = config + self._task_name: str = f"{__class__.__name__}._dispatcher" + + def load(self) -> None: self._handlers: list[type] = self._discover() - timer = config.tasks_handler_timer + timer: str = self._config.tasks_handler_timer try: from cronsim import CronSim @@ -375,18 +387,26 @@ class HandleTask: timer = "15 */1 * * *" LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.") - scheduler.add( + self._scheduler.add( timer=timer, func=self._dispatcher, id=f"{__class__.__name__}._dispatcher", ) + def on_shutdown(self, _: web.Application) -> None: + """ + Handle shutdown event. + + Args: + _: web.Application: The aiohttp application. + + """ + if self._scheduler.has(self._task_name): + self._scheduler.remove(self._task_name) + def _dispatcher(self): for task in self._tasks.get_all(): - if "[no_handler]" in task.name: - continue - - if not task.timer and "[only_handler]" not in task.name: + if not task.handler_enabled: continue try: diff --git a/app/library/Utils.py b/app/library/Utils.py index dd70aff9..e1e949ba 100644 --- a/app/library/Utils.py +++ b/app/library/Utils.py @@ -373,14 +373,18 @@ def check_id(file: Path) -> bool | str: id = match.groupdict().get("id") - for f in file.parent.iterdir(): - if id not in f.stem: - continue + try: + for f in file.parent.iterdir(): + if id not in f.stem: + continue - if f.suffix != file.suffix: - continue + if f.suffix != file.suffix: + continue - return f.absolute() + return f.absolute() + except OSError as e: + LOG.error(f"Error checking file '{file}': {e!s}") + return False return False diff --git a/app/main.py b/app/main.py index bcda05d8..549ace11 100644 --- a/app/main.py +++ b/app/main.py @@ -171,7 +171,6 @@ class Main: self._app, host=host, port=port, - reuse_port="win32" != sys.platform, loop=asyncio.get_event_loop(), access_log=HTTP_LOGGER, print=started, diff --git a/app/routes/api/browser.py b/app/routes/api/browser.py index e6c78fa8..1d09876a 100644 --- a/app/routes/api/browser.py +++ b/app/routes/api/browser.py @@ -324,6 +324,7 @@ async def prepare_zip_file(request: Request, config: Config, cache: Cache): for f in json: if not isinstance(f, str): continue + ref, status = get_file(download_path=config.download_path, file=f) if status == web.HTTPNotFound.status_code: continue diff --git a/app/routes/api/yt_dlp.py b/app/routes/api/yt_dlp.py index 96a3ca27..43d918ee 100644 --- a/app/routes/api/yt_dlp.py +++ b/app/routes/api/yt_dlp.py @@ -105,6 +105,8 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: status=web.HTTPBadRequest.status_code, ) + opts = YTDLPOpts.get_instance() + preset: str = request.query.get("preset", config.default_preset) exists: Preset | None = Presets.get_instance().get(preset) if not exists: @@ -114,14 +116,28 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: status=web.HTTPBadRequest.status_code, ) + if cli_args := request.query.get("args", None): + try: + arg_converter(cli_args, dumps=True) + opts = opts.add_cli(cli_args, from_user=True) + except Exception as e: + err = str(e).strip() + err = err.split("\n")[-1] if "\n" in err else err + err = err.replace("main.py: error: ", "").strip().capitalize() + return web.json_response( + data={"error": f"Failed to parse command options for yt-dlp. '{err}'."}, + status=web.HTTPBadRequest.status_code, + ) + try: - key: str = cache.hash(f"{preset}:{url}") + key: str = cache.hash(f"{preset}:{url}:{cli_args or ''}") if cache.has(key) and not request.query.get("force", False): data: Any | None = cache.get(key) data["_cached"] = { "status": "hit", "preset": preset, + "cli_args": cli_args, "key": key, "ttl": data.get("_cached", {}).get("ttl", 300), "ttl_left": data.get("_cached", {}).get("expires", time.time() + 300) - time.time(), @@ -129,20 +145,18 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: } return web.Response(body=json.dumps(data, indent=4, default=str), status=web.HTTPOk.status_code) - opts: dict = {} - if ytdlp_proxy := config.get_ytdlp_args().get("proxy", None): - opts["proxy"] = ytdlp_proxy + opts = opts.add({"proxy": ytdlp_proxy}) logs: list = [] ytdlp_opts: dict = { + **opts.get_all(), "callback": { "func": lambda _, msg: logs.append(msg), "level": logging.WARNING, "name": "callback-logger", }, - **YTDLPOpts.get_instance().preset(name=preset).add(opts).get_all(), } data = extract_info( @@ -177,6 +191,7 @@ async def get_info(request: Request, cache: Cache, config: Config) -> Response: data["_cached"] = { "status": "miss", "preset": preset, + "cli_args": cli_args, "key": key, "ttl": 300, "ttl_left": 300, diff --git a/ui/app/assets/css/style.css b/ui/app/assets/css/style.css index c2f8644d..99262327 100644 --- a/ui/app/assets/css/style.css +++ b/ui/app/assets/css/style.css @@ -345,3 +345,11 @@ hr { word-break: break-word; text-wrap: auto; } + +table.is-fixed { + table-layout: fixed; +} + +div.is-centered { + justify-content: center; +} diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue index 5a394551..aa833526 100644 --- a/ui/app/components/History.vue +++ b/ui/app/components/History.vue @@ -161,7 +161,7 @@
@@ -188,7 +188,8 @@ - - diff --git a/ui/app/components/NotificationForm.vue b/ui/app/components/NotificationForm.vue index efb46dcb..985cb5fc 100644 --- a/ui/app/components/NotificationForm.vue +++ b/ui/app/components/NotificationForm.vue @@ -198,7 +198,7 @@
- - - diff --git a/ui/app/components/PresetForm.vue b/ui/app/components/PresetForm.vue index c38a2a61..4b33b200 100644 --- a/ui/app/components/PresetForm.vue +++ b/ui/app/components/PresetForm.vue @@ -230,102 +230,74 @@ - diff --git a/ui/app/components/TaskForm.vue b/ui/app/components/TaskForm.vue index cfff4c19..02205c4a 100644 --- a/ui/app/components/TaskForm.vue +++ b/ui/app/components/TaskForm.vue @@ -1,20 +1,10 @@ - diff --git a/ui/app/components/VideoPlayer.vue b/ui/app/components/VideoPlayer.vue index 332698ea..a017c9a8 100644 --- a/ui/app/components/VideoPlayer.vue +++ b/ui/app/components/VideoPlayer.vue @@ -28,6 +28,7 @@ diff --git a/ui/app/pages/conditions.vue b/ui/app/pages/conditions.vue index d8a637d8..97fa328e 100644 --- a/ui/app/pages/conditions.vue +++ b/ui/app/pages/conditions.vue @@ -41,7 +41,7 @@
- +