refactor: migrate tasks to db model
This commit is contained in:
parent
00c3aefff1
commit
fd4e755938
39 changed files with 3910 additions and 3452 deletions
|
|
@ -20,6 +20,7 @@ class CEFeature(str, Enum):
|
||||||
DL_FIELDS = "dl_fields"
|
DL_FIELDS = "dl_fields"
|
||||||
CONDITIONS = "conditions"
|
CONDITIONS = "conditions"
|
||||||
NOTIFICATIONS = "notifications"
|
NOTIFICATIONS = "notifications"
|
||||||
|
TASKS = "tasks"
|
||||||
TASKS_DEFINITIONS = "tasks_definitions"
|
TASKS_DEFINITIONS = "tasks_definitions"
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,22 @@ from typing import Any
|
||||||
import httpx
|
import httpx
|
||||||
from yt_dlp.utils.networking import random_user_agent
|
from yt_dlp.utils.networking import random_user_agent
|
||||||
|
|
||||||
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskResult
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.httpx_client import async_client
|
from app.library.httpx_client import async_client
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
|
||||||
|
|
||||||
|
|
||||||
class BaseHandler:
|
class BaseHandler:
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def inspect(cls, task: Task, config: Config | None = None) -> TaskResult | TaskFailure:
|
async def inspect(cls, task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure:
|
||||||
return await cls.extract(task=task, config=config)
|
return await cls.extract(task=task, config=config)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import jmespath
|
||||||
from parsel import Selector
|
from parsel import Selector
|
||||||
from yt_dlp.utils.networking import random_user_agent
|
from yt_dlp.utils.networking import random_user_agent
|
||||||
|
|
||||||
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.features.tasks.definitions.schemas import (
|
from app.features.tasks.definitions.schemas import (
|
||||||
ExtractionRule,
|
ExtractionRule,
|
||||||
TaskDefinition,
|
TaskDefinition,
|
||||||
|
|
@ -23,7 +24,6 @@ from app.features.tasks.definitions.schemas import (
|
||||||
from app.library.cache import Cache
|
from app.library.cache import Cache
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
from app.library.httpx_client import async_client
|
from app.library.httpx_client import async_client
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
|
||||||
from app.library.Utils import fetch_info, get_archive_id
|
from app.library.Utils import fetch_info, get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -112,7 +112,7 @@ class GenericTaskHandler(BaseHandler):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
"""
|
"""
|
||||||
Determine if this handler can process the given task.
|
Determine if this handler can process the given task.
|
||||||
|
|
||||||
|
|
@ -131,7 +131,7 @@ class GenericTaskHandler(BaseHandler):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task, config: Config | None = None) -> TaskResult | TaskFailure: # noqa: ARG004
|
async def extract(task: HandleTask, config: Config | None = None) -> TaskResult | TaskFailure: # noqa: ARG004
|
||||||
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
|
definition: TaskDefinition | None = await GenericTaskHandler._find_definition(task.url)
|
||||||
if not definition:
|
if not definition:
|
||||||
return TaskFailure(message="No generic task definition matched the provided URL.")
|
return TaskFailure(message="No generic task definition matched the provided URL.")
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.library.cache import Cache
|
from app.library.cache import Cache
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
|
||||||
from app.library.Utils import fetch_info, get_archive_id
|
from app.library.Utils import fetch_info, get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -24,13 +24,13 @@ class RssGenericHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
|
LOG.debug(f"'{task.name}': Checking if task URL is parsable RSS feed: {task.url}")
|
||||||
return RssGenericHandler.parse(task.url) is not None
|
return RssGenericHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get(
|
async def _get(
|
||||||
task: Task,
|
task: HandleTask,
|
||||||
params: dict,
|
params: dict,
|
||||||
parsed: dict[str, str],
|
parsed: dict[str, str],
|
||||||
) -> tuple[str, list[dict[str, str]], int]:
|
) -> tuple[str, list[dict[str, str]], int]:
|
||||||
|
|
@ -131,7 +131,7 @@ class RssGenericHandler(BaseHandler):
|
||||||
return feed_url, items, real_count
|
return feed_url, items, real_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
"""
|
"""
|
||||||
Extract items from an RSS/Atom feed.
|
Extract items from an RSS/Atom feed.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.library.Utils import get_archive_id
|
from app.library.Utils import get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -21,7 +21,7 @@ class TverHandler(BaseHandler):
|
||||||
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
|
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?tver\.jp\/series\/(?P<id>sr[a-z0-9_]+)$")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
|
LOG.debug(f"Checking if task '{task.name}' is using parsable Tver series URL: {task.url}")
|
||||||
return TverHandler.parse(task.url) is not None
|
return TverHandler.parse(task.url) is not None
|
||||||
|
|
||||||
|
|
@ -58,7 +58,7 @@ class TverHandler(BaseHandler):
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _collect_feed(
|
async def _collect_feed(
|
||||||
task: Task,
|
task: HandleTask,
|
||||||
params: dict,
|
params: dict,
|
||||||
series_id: str,
|
series_id: str,
|
||||||
) -> tuple[str, list[dict[str, str]], bool]:
|
) -> tuple[str, list[dict[str, str]], bool]:
|
||||||
|
|
@ -142,7 +142,7 @@ class TverHandler(BaseHandler):
|
||||||
return feed_url, items, has_items
|
return feed_url, items, has_items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
series_id: str | None = TverHandler.parse(task.url)
|
series_id: str | None = TverHandler.parse(task.url)
|
||||||
if not series_id:
|
if not series_id:
|
||||||
return TaskFailure(message="Unrecognized Tver series URL.")
|
return TaskFailure(message="Unrecognized Tver series URL.")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import re
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.library.Utils import get_archive_id
|
from app.library.Utils import get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -20,13 +20,13 @@ class TwitchHandler(BaseHandler):
|
||||||
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?twitch\.tv\/(?P<id>[a-z0-9_]{3,25})(?:\/.*)?$")
|
RX: re.Pattern[str] = re.compile(r"^https?:\/\/(?:www\.|m\.)?twitch\.tv\/(?P<id>[a-z0-9_]{3,25})(?:\/.*)?$")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
|
LOG.debug(f"Checking if task '{task.name}' is using parsable Twitch URL: {task.url}")
|
||||||
return TwitchHandler.parse(task.url) is not None
|
return TwitchHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _collect_feed(
|
async def _collect_feed(
|
||||||
task: Task,
|
task: HandleTask,
|
||||||
params: dict,
|
params: dict,
|
||||||
handle_name: str,
|
handle_name: str,
|
||||||
) -> tuple[str, list[dict[str, str]], bool]:
|
) -> tuple[str, list[dict[str, str]], bool]:
|
||||||
|
|
@ -74,7 +74,7 @@ class TwitchHandler(BaseHandler):
|
||||||
return feed_url, items, has_items
|
return feed_url, items, has_items
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||||
if not handle_name:
|
if not handle_name:
|
||||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import re
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
from xml.etree.ElementTree import Element
|
from xml.etree.ElementTree import Element
|
||||||
|
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
from app.library.Utils import get_archive_id
|
from app.library.Utils import get_archive_id
|
||||||
|
|
||||||
from ._base_handler import BaseHandler
|
from ._base_handler import BaseHandler
|
||||||
|
|
@ -26,12 +26,12 @@ class YoutubeHandler(BaseHandler):
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def can_handle(task: Task) -> bool:
|
async def can_handle(task: HandleTask) -> bool:
|
||||||
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
LOG.debug(f"'{task.name}': Checking if task URL is parsable YouTube URL: {task.url}")
|
||||||
return YoutubeHandler.parse(task.url) is not None
|
return YoutubeHandler.parse(task.url) is not None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _get(task: Task, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
async def _get(task: HandleTask, params: dict, parsed: dict[str, str]) -> tuple[str, list[dict[str, str]], int]:
|
||||||
"""
|
"""
|
||||||
Fetch the feed and return raw entries.
|
Fetch the feed and return raw entries.
|
||||||
|
|
||||||
|
|
@ -89,7 +89,7 @@ class YoutubeHandler(BaseHandler):
|
||||||
return feed_url, items, real_count
|
return feed_url, items, real_count
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||||
if not parsed:
|
if not parsed:
|
||||||
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
return TaskFailure(message="Unrecognized YouTube channel or playlist URL.")
|
||||||
|
|
|
||||||
226
app/features/tasks/definitions/results.py
Normal file
226
app/features/tasks/definitions/results.py
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.features.tasks.schemas import Task as TaskSchema
|
||||||
|
from app.library.YTDLPOpts import YTDLPOpts
|
||||||
|
|
||||||
|
from .utils import split_inspect_metadata
|
||||||
|
|
||||||
|
|
||||||
|
class HandleTask(TaskSchema):
|
||||||
|
def get_ytdlp_opts(self) -> YTDLPOpts:
|
||||||
|
"""
|
||||||
|
Get the yt-dlp options for the task.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
YTDLPOpts: The yt-dlp options.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from app.library.YTDLPOpts import YTDLPOpts
|
||||||
|
|
||||||
|
params: YTDLPOpts = YTDLPOpts.get_instance()
|
||||||
|
|
||||||
|
if self.preset:
|
||||||
|
params = params.preset(name=self.preset)
|
||||||
|
|
||||||
|
if self.cli:
|
||||||
|
params = params.add_cli(self.cli, from_user=True)
|
||||||
|
|
||||||
|
if self.template:
|
||||||
|
params = params.add({"outtmpl": {"default": self.template}}, from_user=False)
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
async def mark(self) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Mark the task's items as downloaded in the archive file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, str]: A tuple indicating success and a message.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from app.library.Utils import archive_add
|
||||||
|
|
||||||
|
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
|
||||||
|
if isinstance(ret, tuple):
|
||||||
|
return ret
|
||||||
|
|
||||||
|
archive_file: Path = ret.get("file")
|
||||||
|
items: set[str] = ret.get("items", set())
|
||||||
|
|
||||||
|
if len(items) < 1 or not archive_add(archive_file, list(items)):
|
||||||
|
return (True, "No new items to mark as downloaded.")
|
||||||
|
|
||||||
|
return (True, f"Task '{self.name}' items marked as downloaded.")
|
||||||
|
|
||||||
|
async def unmark(self) -> tuple[bool, str]:
|
||||||
|
"""
|
||||||
|
Unmark the task's items from the archive file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, str]: A tuple indicating success and a message.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from app.library.Utils import archive_delete
|
||||||
|
|
||||||
|
ret: tuple[bool, str] | dict[str, Any] = await self._mark_logic()
|
||||||
|
if isinstance(ret, tuple):
|
||||||
|
return ret
|
||||||
|
|
||||||
|
archive_file: Path = ret.get("file")
|
||||||
|
items: set[str] = ret.get("items", set())
|
||||||
|
|
||||||
|
if len(items) < 1 or not archive_delete(archive_file, list(items)):
|
||||||
|
return (True, "No items to remove from archive file.")
|
||||||
|
|
||||||
|
return (True, f"Removed '{self.name}' items from archive file.")
|
||||||
|
|
||||||
|
async def fetch_metadata(self, full: bool = False) -> tuple[dict[str, Any] | None, bool, str]:
|
||||||
|
"""
|
||||||
|
Fetch metadata for the task's URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
full (bool): Whether to fetch full metadata including all entries for playlists.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[dict[str, Any]|None, bool, str]: A tuple containing the metadata (or None on failure), a boolean
|
||||||
|
indicating if the operation was successful, and a message.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from app.library.ytdlp import fetch_info
|
||||||
|
|
||||||
|
if not self.url:
|
||||||
|
return ({}, False, "No URL found in task parameters.")
|
||||||
|
|
||||||
|
params = self.get_ytdlp_opts()
|
||||||
|
if not full:
|
||||||
|
params.add_cli("-I0", from_user=False)
|
||||||
|
|
||||||
|
params_dict = params.get_all()
|
||||||
|
|
||||||
|
ie_info: dict | None = await fetch_info(
|
||||||
|
params_dict,
|
||||||
|
self.url,
|
||||||
|
no_archive=True,
|
||||||
|
follow_redirect=False,
|
||||||
|
sanitize_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not ie_info or not isinstance(ie_info, dict):
|
||||||
|
return ({}, False, "Failed to extract information from URL.")
|
||||||
|
|
||||||
|
return (ie_info, True, "")
|
||||||
|
|
||||||
|
async def _mark_logic(self) -> tuple[bool, str] | dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Internal logic for marking/un-marking items.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[bool, str] | dict[str, Any]: Either an error tuple or a dict with 'file' and 'items' keys.
|
||||||
|
|
||||||
|
"""
|
||||||
|
from app.library.ytdlp import fetch_info
|
||||||
|
|
||||||
|
if not self.url:
|
||||||
|
return (False, "No URL found in task parameters.")
|
||||||
|
|
||||||
|
params: dict = self.get_ytdlp_opts().get_all()
|
||||||
|
if not (archive_file := params.get("download_archive")):
|
||||||
|
return (False, "No archive file found.")
|
||||||
|
|
||||||
|
archive_file: Path = Path(archive_file)
|
||||||
|
|
||||||
|
ie_info: dict | None = await fetch_info(params, self.url, no_archive=True, follow_redirect=True)
|
||||||
|
if not ie_info or not isinstance(ie_info, dict):
|
||||||
|
return (False, "Failed to extract information from URL.")
|
||||||
|
|
||||||
|
if "playlist" != ie_info.get("_type"):
|
||||||
|
return (False, "Expected a playlist type from extract_info.")
|
||||||
|
|
||||||
|
items: set[str] = set()
|
||||||
|
|
||||||
|
def _process(item: dict):
|
||||||
|
for entry in item.get("entries", []):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if "playlist" == entry.get("_type"):
|
||||||
|
_process(entry)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if entry.get("_type") not in ("video", "url"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not entry.get("id") or not entry.get("ie_key"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
archive_id: str = f"{entry.get('ie_key', '').lower()} {entry.get('id')}"
|
||||||
|
|
||||||
|
items.add(archive_id)
|
||||||
|
|
||||||
|
_process(ie_info)
|
||||||
|
|
||||||
|
return {"file": archive_file, "items": items}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TaskItem:
|
||||||
|
"""Represents a single item in a task result."""
|
||||||
|
|
||||||
|
url: str
|
||||||
|
"The URL of the item."
|
||||||
|
title: str | None = None
|
||||||
|
"The title of the item."
|
||||||
|
archive_id: str | None = None
|
||||||
|
"The archive ID of the item."
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
"Additional metadata related to the item."
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TaskResult:
|
||||||
|
"""Represents a successful task handler execution result."""
|
||||||
|
|
||||||
|
items: list[TaskItem] = field(default_factory=list)
|
||||||
|
"The list of items."
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
"Additional metadata related to the result."
|
||||||
|
|
||||||
|
def serialize(self) -> dict[str, Any]:
|
||||||
|
primary, extra = split_inspect_metadata(self.metadata)
|
||||||
|
payload: dict[str, Any] = {**primary, "items": [asdict(item) for item in self.items]}
|
||||||
|
|
||||||
|
if extra:
|
||||||
|
payload["metadata"] = extra
|
||||||
|
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TaskFailure:
|
||||||
|
"""Represents a failed task handler execution result."""
|
||||||
|
|
||||||
|
message: str
|
||||||
|
"A human-readable message describing the failure."
|
||||||
|
error: str | None = None
|
||||||
|
"An optional error code or string."
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
"Additional metadata related to the failure."
|
||||||
|
|
||||||
|
def serialize(self) -> dict[str, Any]:
|
||||||
|
primary, extra = split_inspect_metadata(self.metadata)
|
||||||
|
payload: dict[str, Any] = dict(primary)
|
||||||
|
|
||||||
|
if self.error:
|
||||||
|
payload["error"] = self.error
|
||||||
|
|
||||||
|
if self.message and (not self.error or self.message != self.error):
|
||||||
|
payload["message"] = self.message
|
||||||
|
|
||||||
|
if extra:
|
||||||
|
payload["metadata"] = extra
|
||||||
|
|
||||||
|
return payload
|
||||||
456
app/features/tasks/definitions/service.py
Normal file
456
app/features/tasks/definitions/service.py
Normal file
|
|
@ -0,0 +1,456 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib
|
||||||
|
import inspect
|
||||||
|
import logging
|
||||||
|
import pkgutil
|
||||||
|
import random
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from app.features.tasks.definitions.results import HandleTask, TaskFailure, TaskItem, TaskResult
|
||||||
|
from app.features.tasks.models import TaskModel
|
||||||
|
from app.library.downloads.queue_manager import DownloadQueue
|
||||||
|
from app.library.Events import EventBus, Events
|
||||||
|
from app.library.ItemDTO import Item, ItemDTO
|
||||||
|
from app.library.Services import Services
|
||||||
|
from app.library.Utils import archive_read
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.features.tasks.repository import TasksRepository
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.Scheduler import Scheduler
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger("tasks.definitions.service")
|
||||||
|
|
||||||
|
|
||||||
|
class TaskHandle:
|
||||||
|
def __init__(self, scheduler: Scheduler, tasks: TasksRepository, config: Config) -> None:
|
||||||
|
self._handlers: list[type] = []
|
||||||
|
"The available handlers."
|
||||||
|
self._repo: TasksRepository = tasks
|
||||||
|
"The tasks manager."
|
||||||
|
self._scheduler: Scheduler = scheduler
|
||||||
|
"The scheduler."
|
||||||
|
self._config: Config = config
|
||||||
|
"The configuration."
|
||||||
|
self._task_name: str = f"{__class__.__name__}._dispatcher"
|
||||||
|
"The task name for the scheduler."
|
||||||
|
self._queued: dict[str, set[str]] = {}
|
||||||
|
"Queued archive IDs per handler."
|
||||||
|
self._failure_count: dict[str, dict[str, int]] = {}
|
||||||
|
"Failure counts per handler and archive ID."
|
||||||
|
|
||||||
|
EventBus.get_instance().subscribe(
|
||||||
|
Events.ITEM_ERROR,
|
||||||
|
self._handle_item_error,
|
||||||
|
f"{__class__.__name__}.item_error",
|
||||||
|
)
|
||||||
|
|
||||||
|
def load(self) -> None:
|
||||||
|
self._handlers: list[type] = self._discover()
|
||||||
|
|
||||||
|
timer: str = self._config.tasks_handler_timer
|
||||||
|
try:
|
||||||
|
from cronsim import CronSim
|
||||||
|
|
||||||
|
CronSim(timer, datetime.now(UTC))
|
||||||
|
except Exception as e:
|
||||||
|
timer = "15 */1 * * *"
|
||||||
|
LOG.error(f"Invalid timer format. '{e!s}'. Defaulting to '{timer}'.")
|
||||||
|
|
||||||
|
self._scheduler.add(
|
||||||
|
timer=timer,
|
||||||
|
func=lambda: asyncio.create_task(self._dispatcher(), name="task-handler-dispatcher"),
|
||||||
|
id=f"{__class__.__name__}._dispatcher",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _dispatcher(self):
|
||||||
|
s: dict[str, list[str]] = {"h": [], "d": [], "u": [], "f": []}
|
||||||
|
|
||||||
|
handler_groups: dict[str, list[tuple[HandleTask, type]]] = {}
|
||||||
|
|
||||||
|
tasks: list[TaskModel] = await self._repo.list()
|
||||||
|
|
||||||
|
for task_model in tasks:
|
||||||
|
task: HandleTask = HandleTask.model_validate(task_model)
|
||||||
|
|
||||||
|
if not task.enabled or not task.handler_enabled:
|
||||||
|
s["d"].append(task.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not task.get_ytdlp_opts().get_all().get("download_archive"):
|
||||||
|
LOG.debug(f"Task '{task.name}' does not have an archive file configured.")
|
||||||
|
s["f"].append(task.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
handler: type | None = await self._find_handler(task)
|
||||||
|
if handler is None:
|
||||||
|
s["u"].append(task.name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
handler_name: str = handler.__name__
|
||||||
|
if handler_name not in handler_groups:
|
||||||
|
handler_groups[handler_name] = []
|
||||||
|
handler_groups[handler_name].append((task, handler))
|
||||||
|
s["h"].append(task.name)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Failed to handle task '{task.name}'. '{e!s}'.")
|
||||||
|
s["f"].append(task.name)
|
||||||
|
|
||||||
|
for tasks_with_handlers in handler_groups.values():
|
||||||
|
for idx, (task, handler) in enumerate(tasks_with_handlers):
|
||||||
|
try:
|
||||||
|
t: asyncio.Task[TaskResult | TaskFailure | None] = asyncio.create_task(
|
||||||
|
coro=self._dispatch(
|
||||||
|
task,
|
||||||
|
handler,
|
||||||
|
delay=0.0 if 0 == idx else random.uniform(1.0, self._config.task_handler_random_delay),
|
||||||
|
),
|
||||||
|
name=f"taskHandler-{task.id}",
|
||||||
|
)
|
||||||
|
t.add_done_callback(lambda fut, t=task: self._handle_exception(fut, t))
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Failed to dispatch task '{task.name}'. '{e!s}'.")
|
||||||
|
|
||||||
|
if len(tasks) > 0:
|
||||||
|
LOG.info(
|
||||||
|
f"Tasks handler summary: Handled: {len(s['h'])}, Unhandled: {len(s['u'])}, Disabled: {len(s['d'])}, Failed: {len(s['f'])}."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _dispatch(self, task: HandleTask, handler: type, delay: float) -> TaskResult | TaskFailure | None:
|
||||||
|
"""
|
||||||
|
Dispatch a task after a random delay to avoid rate limiting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The task to dispatch.
|
||||||
|
handler: The handler to use.
|
||||||
|
delay: The delay in seconds before dispatching.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The dispatch result.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if delay > 0:
|
||||||
|
LOG.debug(f"Delaying dispatch of task '{task.name}' by {delay:.1f} seconds.")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return await self.dispatch(task, handler=handler)
|
||||||
|
|
||||||
|
def _handle_exception(self, fut: asyncio.Task, task: HandleTask) -> None:
|
||||||
|
if fut.cancelled():
|
||||||
|
return
|
||||||
|
|
||||||
|
if exc := fut.exception():
|
||||||
|
LOG.error(f"Exception while handling task '{task.name}': {exc}")
|
||||||
|
|
||||||
|
async def _find_handler(self, task: HandleTask) -> type | None:
|
||||||
|
for cls in self._handlers:
|
||||||
|
try:
|
||||||
|
if await Services.get_instance().handle_async(handler=cls.can_handle, task=task):
|
||||||
|
return cls
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def dispatch(
|
||||||
|
self,
|
||||||
|
task: HandleTask,
|
||||||
|
handler: type | None = None,
|
||||||
|
**kwargs, # noqa: ARG002
|
||||||
|
) -> TaskResult | TaskFailure | None:
|
||||||
|
"""
|
||||||
|
Dispatch a task to the appropriate handler.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The task to dispatch.
|
||||||
|
handler: Optional specific handler to use instead of finding one.
|
||||||
|
**kwargs: Additional context to pass to the handler.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The extraction outcome, or None if no handler matched.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not handler:
|
||||||
|
handler = await self._find_handler(task)
|
||||||
|
if handler is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
services: Services = Services.get_instance()
|
||||||
|
|
||||||
|
try:
|
||||||
|
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||||
|
handler=handler.extract, task=task, config=self._config
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
LOG.error(f"Handler '{handler.__name__}' does not implement extract().")
|
||||||
|
return TaskFailure(message="Handler does not support extraction.")
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.exception(exc)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if isinstance(extraction, TaskFailure):
|
||||||
|
LOG.error(f"Handler '{handler.__name__}' failed to extract items: {extraction.message}")
|
||||||
|
return extraction
|
||||||
|
|
||||||
|
if not isinstance(extraction, TaskResult):
|
||||||
|
LOG.error(
|
||||||
|
f"Handler '{handler.__name__}' returned unexpected result type '{type(extraction).__name__}'.",
|
||||||
|
)
|
||||||
|
return TaskFailure(
|
||||||
|
message="Handler returned invalid result type.", metadata={"type": type(extraction).__name__}
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_items: list[TaskItem] = extraction.items or []
|
||||||
|
metadata: dict[str, Any] = extraction.metadata or {}
|
||||||
|
|
||||||
|
handler_name: str = handler.__name__
|
||||||
|
queued: set[str] = self._queued.setdefault(handler_name, set())
|
||||||
|
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||||
|
|
||||||
|
params: dict = task.get_ytdlp_opts().get_all()
|
||||||
|
archive_file: str | None = params.get("download_archive")
|
||||||
|
|
||||||
|
download_queue: DownloadQueue = services.get("queue") or DownloadQueue.get_instance()
|
||||||
|
notify: EventBus = services.get("notify") or EventBus.get_instance()
|
||||||
|
|
||||||
|
archive_ids: list[str] = [
|
||||||
|
item.archive_id for item in raw_items if isinstance(item, TaskItem) and item.archive_id
|
||||||
|
]
|
||||||
|
downloaded: list[str] = archive_read(archive_file, archive_ids) if archive_file else []
|
||||||
|
|
||||||
|
filtered: list[TaskItem] = []
|
||||||
|
|
||||||
|
for item in raw_items:
|
||||||
|
if not isinstance(item, TaskItem):
|
||||||
|
LOG.warning("Handler '{handler.__name__}' produced non-TaskItem entry: {item!r}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
url: str = item.url
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
archive_id: str | None = item.archive_id
|
||||||
|
if not archive_id:
|
||||||
|
LOG.warning(f"'{task.name}': Item with URL '{url}' is missing an archive ID. Skipping.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if archive_id in queued:
|
||||||
|
continue
|
||||||
|
|
||||||
|
queued.add(archive_id)
|
||||||
|
|
||||||
|
if archive_file and archive_id in downloaded:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if await download_queue.queue.exists(url=url):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
done = await download_queue.done.get(url=url)
|
||||||
|
if "error" != done.info.status:
|
||||||
|
continue
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if archive_id not in failures:
|
||||||
|
failures[archive_id] = 0
|
||||||
|
|
||||||
|
filtered.append(item)
|
||||||
|
|
||||||
|
if not filtered:
|
||||||
|
if raw_items:
|
||||||
|
LOG.debug(
|
||||||
|
f"Handler '{handler.__name__}' produced '{len(raw_items)}' for '{task.name}' items, none queued after filtering."
|
||||||
|
)
|
||||||
|
return TaskResult(items=[], metadata=metadata)
|
||||||
|
|
||||||
|
LOG.info(
|
||||||
|
f"Handler '{handler.__name__}' Found '{len(filtered)}' new items for '{task.name}' (raw={len(raw_items)})."
|
||||||
|
)
|
||||||
|
|
||||||
|
base_item = Item.format(
|
||||||
|
{
|
||||||
|
"url": task.url,
|
||||||
|
"preset": task.preset or self._config.default_preset,
|
||||||
|
"folder": task.folder or "",
|
||||||
|
"template": task.template or "",
|
||||||
|
"cli": task.cli or "",
|
||||||
|
"auto_start": task.auto_start,
|
||||||
|
"extras": {"source_name": task.name, "source_id": task.id, "source_handler": handler.__name__},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for item in filtered:
|
||||||
|
metadata_entry: dict[str, Any] = item.metadata if isinstance(item.metadata, dict) else {}
|
||||||
|
extras: dict[str, Any] = base_item.extras.copy()
|
||||||
|
if metadata_entry:
|
||||||
|
extras["metadata"] = metadata_entry
|
||||||
|
|
||||||
|
notify.emit(
|
||||||
|
Events.ADD_URL,
|
||||||
|
data=base_item.new_with(url=item.url, extras=extras).serialize(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return TaskResult(items=filtered, metadata=metadata)
|
||||||
|
|
||||||
|
async def inspect(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
preset: str | None = None,
|
||||||
|
handler_name: str | None = None,
|
||||||
|
static_only: bool = False,
|
||||||
|
) -> TaskResult | TaskFailure:
|
||||||
|
"""
|
||||||
|
Inspect a URL to find a matching handler and optionally extract items.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: The URL to inspect.
|
||||||
|
preset: Optional preset name to use.
|
||||||
|
handler_name: Optional specific handler name to use.
|
||||||
|
static_only: If True, only check if a handler matches without extraction.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
TaskResult or TaskFailure with inspection results.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not self._handlers:
|
||||||
|
self._handlers = self._discover()
|
||||||
|
|
||||||
|
task = HandleTask(
|
||||||
|
id=None,
|
||||||
|
name="Inspector",
|
||||||
|
url=url,
|
||||||
|
preset=preset or self._config.default_preset,
|
||||||
|
auto_start=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
services = Services.get_instance()
|
||||||
|
|
||||||
|
handler_cls: type | None
|
||||||
|
if handler_name:
|
||||||
|
handler_cls = next((cls for cls in self._handlers if cls.__name__.lower() == handler_name.lower()), None)
|
||||||
|
if handler_cls is None:
|
||||||
|
message: str = f"Handler '{handler_name}' not found."
|
||||||
|
return TaskFailure(
|
||||||
|
message=message,
|
||||||
|
error=message,
|
||||||
|
metadata={"matched": False, "handler": handler_name},
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
matched = await services.handle_async(handler=handler_cls.can_handle, task=task)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
LOG.exception(exc)
|
||||||
|
message = str(exc)
|
||||||
|
return TaskFailure(
|
||||||
|
message=message,
|
||||||
|
error=message,
|
||||||
|
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||||
|
)
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
return TaskFailure(
|
||||||
|
message="Handler cannot process the supplied URL.",
|
||||||
|
metadata={"matched": False, "handler": handler_cls.__name__},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
handler_cls = await self._find_handler(task)
|
||||||
|
if handler_cls is None:
|
||||||
|
message = "No handler matched the supplied URL."
|
||||||
|
return TaskFailure(
|
||||||
|
message=message,
|
||||||
|
error=message,
|
||||||
|
metadata={"matched": False, "handler": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
base_metadata: dict[str, Any] = {"matched": True, "handler": handler_cls.__name__}
|
||||||
|
|
||||||
|
if static_only:
|
||||||
|
return TaskResult(items=[], metadata=base_metadata)
|
||||||
|
|
||||||
|
try:
|
||||||
|
extraction: TaskResult | TaskFailure = await services.handle_async(
|
||||||
|
handler=handler_cls.extract, task=task, config=self._config
|
||||||
|
)
|
||||||
|
except NotImplementedError:
|
||||||
|
return TaskFailure(
|
||||||
|
message="Handler does not support manual inspection.",
|
||||||
|
metadata={**base_metadata, "supported": False},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.exception(exc)
|
||||||
|
message = str(exc)
|
||||||
|
return TaskFailure(
|
||||||
|
message=message,
|
||||||
|
error=message,
|
||||||
|
metadata={**base_metadata, "supported": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(extraction, TaskFailure):
|
||||||
|
combined_failure_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||||
|
if extraction.metadata:
|
||||||
|
combined_failure_metadata.update(extraction.metadata)
|
||||||
|
|
||||||
|
return TaskFailure(
|
||||||
|
message=extraction.message,
|
||||||
|
error=extraction.error if extraction.error else extraction.message,
|
||||||
|
metadata=combined_failure_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not isinstance(extraction, TaskResult):
|
||||||
|
LOG.error(
|
||||||
|
f"Handler '{handler_cls.__name__}' returned unexpected result type '{type(extraction).__name__}' during inspection.",
|
||||||
|
)
|
||||||
|
extraction = TaskResult()
|
||||||
|
|
||||||
|
combined_metadata: dict[str, Any] = {**base_metadata, "supported": True}
|
||||||
|
if extraction.metadata:
|
||||||
|
combined_metadata.update(extraction.metadata)
|
||||||
|
|
||||||
|
return TaskResult(items=list(extraction.items), metadata=combined_metadata)
|
||||||
|
|
||||||
|
def _discover(self) -> list[type]:
|
||||||
|
"""Discover all available task handlers."""
|
||||||
|
import app.features.tasks.definitions.handlers as handlers_pkg
|
||||||
|
|
||||||
|
handlers: list[type] = []
|
||||||
|
|
||||||
|
for _, module_name, _ in pkgutil.iter_modules(handlers_pkg.__path__):
|
||||||
|
if module_name.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
module = importlib.import_module(f"{handlers_pkg.__name__}.{module_name}")
|
||||||
|
for _, cls in inspect.getmembers(module, inspect.isclass):
|
||||||
|
if cls.__module__ != module.__name__:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if callable(getattr(cls, "can_handle", None)) and callable(getattr(cls, "extract", None)):
|
||||||
|
handlers.append(cls)
|
||||||
|
|
||||||
|
return handlers
|
||||||
|
|
||||||
|
async def _handle_item_error(self, event, _name, **_kwargs):
|
||||||
|
"""Handle item error events to clean up queued items and track failures."""
|
||||||
|
item: ItemDTO | None = getattr(event, "data", None)
|
||||||
|
if not isinstance(item, ItemDTO):
|
||||||
|
return
|
||||||
|
|
||||||
|
extras: dict[Any, Any] = getattr(item, "extras", {}) or {}
|
||||||
|
handler_name: Any | None = extras.get("source_handler")
|
||||||
|
if not handler_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
archive_id: str | None = item.archive_id
|
||||||
|
if not archive_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
queued: set[str] | None = self._queued.get(handler_name)
|
||||||
|
if queued:
|
||||||
|
queued.discard(archive_id)
|
||||||
|
|
||||||
|
failures: dict[str, int] = self._failure_count.setdefault(handler_name, {})
|
||||||
|
failures[archive_id] = failures.get(archive_id, 0) + 1
|
||||||
|
|
@ -4,14 +4,15 @@ from unittest.mock import patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.generic import GenericTaskHandler
|
from app.features.tasks.definitions.handlers.generic import GenericTaskHandler
|
||||||
|
from app.features.tasks.definitions.results import TaskFailure, TaskResult
|
||||||
from app.features.tasks.definitions.schemas import (
|
from app.features.tasks.definitions.schemas import (
|
||||||
|
Definition,
|
||||||
EngineConfig,
|
EngineConfig,
|
||||||
RequestConfig,
|
RequestConfig,
|
||||||
ResponseConfig,
|
ResponseConfig,
|
||||||
TaskDefinition,
|
TaskDefinition,
|
||||||
Definition,
|
|
||||||
)
|
)
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -341,7 +342,7 @@ async def test_generic_task_handler_inspect(monkeypatch):
|
||||||
return {"id": "test_video_1", "extractor_key": "Example"}
|
return {"id": "test_video_1", "extractor_key": "Example"}
|
||||||
|
|
||||||
with patch("app.features.tasks.definitions.handlers.generic.fetch_info", side_effect=fake_fetch_info):
|
with patch("app.features.tasks.definitions.handlers.generic.fetch_info", side_effect=fake_fetch_info):
|
||||||
task = Task(id="inspect", name="Inspect", url="https://example.com/api")
|
task = HandleTask(id=1, name="Inspect", url="https://example.com/api")
|
||||||
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
result: TaskResult | TaskFailure = await GenericTaskHandler.extract(task)
|
||||||
|
|
||||||
assert isinstance(result, TaskResult), "Result should be TaskResult"
|
assert isinstance(result, TaskResult), "Result should be TaskResult"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
from app.features.tasks.definitions.handlers.rss import RssGenericHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -68,10 +69,10 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(atom_feed)
|
return DummyResponse(atom_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="test_rss",
|
id=1,
|
||||||
name="Test Atom Feed",
|
name="Test Atom Feed",
|
||||||
url="https://example.com/feed.atom",
|
url="https://example.com/feed.atom",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -112,10 +113,10 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(rss_feed)
|
return DummyResponse(rss_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="test_rss",
|
id=1,
|
||||||
name="Test RSS Feed",
|
name="Test RSS Feed",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -146,19 +147,19 @@ class TestRssHandlerExtraction:
|
||||||
return DummyResponse(atom_feed)
|
return DummyResponse(atom_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="test_rss",
|
id=1,
|
||||||
name="Test Feed",
|
name="Test rss Feed",
|
||||||
url="https://example.com/feed.atom",
|
url="https://example.com/feed.atom",
|
||||||
preset="default",
|
preset="default",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert await RssGenericHandler.can_handle(task) is True
|
assert await RssGenericHandler.can_handle(task) is True
|
||||||
|
|
||||||
non_feed_task = Task(
|
non_feed_task = HandleTask(
|
||||||
id="test_youtube",
|
id=1,
|
||||||
name="YouTube Video",
|
name="YouTube Video",
|
||||||
url="https://www.youtube.com/watch?v=abc123",
|
url="https://www.youtube.com/watch?v=abc123",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -185,10 +186,10 @@ class TestRssHandlerEdgeCases:
|
||||||
return DummyResponse(empty_feed)
|
return DummyResponse(empty_feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="test_empty",
|
id=1,
|
||||||
name="Empty Feed",
|
name="Empty Feed",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -203,17 +204,17 @@ class TestRssHandlerEdgeCases:
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_invalid_feed_url(self, monkeypatch):
|
async def test_invalid_feed_url(self, monkeypatch):
|
||||||
"""Test handling of invalid feed URL."""
|
"""Test handling of invalid feed URL."""
|
||||||
from app.library.Tasks import TaskFailure
|
from app.features.tasks.definitions.results import TaskFailure
|
||||||
|
|
||||||
async def fake_request(**kwargs): # noqa: ARG001
|
async def fake_request(**kwargs): # noqa: ARG001
|
||||||
msg = "Network error"
|
msg = "Network error"
|
||||||
raise Exception(msg)
|
raise Exception(msg)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="test_invalid",
|
id=1,
|
||||||
name="Invalid Feed",
|
name="Invalid Feed",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -244,10 +245,10 @@ class TestRssHandlerEdgeCases:
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(RssGenericHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="test_missing",
|
id=1,
|
||||||
name="Feed with Missing URLs",
|
name="Feed with Missing URLs",
|
||||||
url="https://example.com/feed.rss",
|
url="https://example.com/feed.rss",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.tver import TverHandler
|
from app.features.tasks.definitions.handlers.tver import TverHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -118,9 +119,9 @@ async def test_tver_handler_extract(monkeypatch):
|
||||||
raise RuntimeError(msg)
|
raise RuntimeError(msg)
|
||||||
|
|
||||||
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(TverHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda _: DummyOpts({"download_archive": "/tmp/archive"}))
|
||||||
|
|
||||||
task = Task(id="test_tver", name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
task = HandleTask(id=1, name="Test Tver Series", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||||
|
|
||||||
result = await TverHandler.extract(task)
|
result = await TverHandler.extract(task)
|
||||||
|
|
||||||
|
|
@ -154,7 +155,7 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tver_handler_can_handle():
|
async def test_tver_handler_can_handle():
|
||||||
"""Test tver handler can_handle method."""
|
"""Test tver handler can_handle method."""
|
||||||
task_valid = Task(id="test1", name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
task_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", preset="default")
|
||||||
task_invalid = Task(id="test2", name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
task_invalid = HandleTask(id=2, name="Test", url="https://youtube.com/watch?v=123", preset="default")
|
||||||
assert await TverHandler.can_handle(task_valid) is True
|
assert await TverHandler.can_handle(task_valid) is True
|
||||||
assert await TverHandler.can_handle(task_invalid) is False
|
assert await TverHandler.can_handle(task_invalid) is False
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
from app.features.tasks.definitions.handlers.twitch import TwitchHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -39,10 +40,10 @@ async def test_twitch_handler_inspect(monkeypatch):
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(TwitchHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="inspect",
|
id=1,
|
||||||
name="Inspect",
|
name="Inspect",
|
||||||
url="https://www.twitch.tv/testchannel",
|
url="https://www.twitch.tv/testchannel",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
from app.features.tasks.definitions.handlers.youtube import YoutubeHandler
|
||||||
from app.library.Tasks import Task, TaskResult
|
from app.features.tasks.definitions.results import TaskResult
|
||||||
|
from app.features.tasks.definitions.results import HandleTask
|
||||||
|
|
||||||
|
|
||||||
class DummyResponse:
|
class DummyResponse:
|
||||||
|
|
@ -41,10 +42,10 @@ async def test_youtube_handler_inspect(monkeypatch):
|
||||||
return DummyResponse(feed)
|
return DummyResponse(feed)
|
||||||
|
|
||||||
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
monkeypatch.setattr(YoutubeHandler, "request", staticmethod(fake_request))
|
||||||
monkeypatch.setattr(Task, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
monkeypatch.setattr(HandleTask, "get_ytdlp_opts", lambda self: DummyOpts({"download_archive": "/tmp/archive"})) # noqa: ARG005
|
||||||
|
|
||||||
task = Task(
|
task = HandleTask(
|
||||||
id="inspect",
|
id=1,
|
||||||
name="Inspect",
|
name="Inspect",
|
||||||
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||||
preset="default",
|
preset="default",
|
||||||
|
|
@ -46,3 +46,24 @@ def schema_to_payload(item: TaskDefinition) -> dict[str, Any]:
|
||||||
"enabled": item.enabled,
|
"enabled": item.enabled,
|
||||||
"definition": item.definition.model_dump(exclude_unset=True, exclude_none=True),
|
"definition": item.definition.model_dump(exclude_unset=True, exclude_none=True),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def split_inspect_metadata(metadata: dict[str, Any] | None) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Split commonly consumed metadata keys from the rest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metadata (dict[str, Any]|None): The metadata to split.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[dict[str, Any], dict[str, Any]]: The primary and extra metadata.
|
||||||
|
|
||||||
|
"""
|
||||||
|
metadata = dict(metadata or {})
|
||||||
|
primary: dict[str, Any] = {}
|
||||||
|
|
||||||
|
for key in ("matched", "handler", "supported"):
|
||||||
|
if key in metadata:
|
||||||
|
primary[key] = metadata.pop(key)
|
||||||
|
|
||||||
|
return primary, metadata
|
||||||
|
|
|
||||||
14
app/features/tasks/deps.py
Normal file
14
app/features/tasks/deps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.features.tasks.repository import TasksRepository
|
||||||
|
from app.features.tasks.service import Tasks
|
||||||
|
|
||||||
|
|
||||||
|
def get_tasks_repo() -> TasksRepository:
|
||||||
|
"""Get tasks repository instance."""
|
||||||
|
return TasksRepository.get_instance()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tasks_service() -> Tasks:
|
||||||
|
"""Get tasks service instance."""
|
||||||
|
return Tasks.get_instance()
|
||||||
116
app/features/tasks/migration.py
Normal file
116
app/features/tasks/migration.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from app.features.core.migration import Migration as FeatureMigration
|
||||||
|
from app.features.tasks.schemas import Task
|
||||||
|
from app.library.config import Config
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.features.tasks.repository import TasksRepository
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(FeatureMigration):
|
||||||
|
name: str = "tasks"
|
||||||
|
|
||||||
|
def __init__(self, repo: TasksRepository, config: Config | None = None):
|
||||||
|
self._config: Config = config or Config.get_instance()
|
||||||
|
super().__init__(config=self._config)
|
||||||
|
self._repo: TasksRepository = repo
|
||||||
|
self._source_file: Path = Path(self._config.config_path) / "tasks.json"
|
||||||
|
|
||||||
|
async def should_run(self) -> bool:
|
||||||
|
return self._source_file.exists()
|
||||||
|
|
||||||
|
async def migrate(self) -> None:
|
||||||
|
if await self._repo.count() > 0:
|
||||||
|
LOG.warning("Tasks already exist in the database; skipping migration.")
|
||||||
|
await self._move_file(self._source_file)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
items: list[dict[str, Any]] | None = json.loads(self._source_file.read_text())
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.exception("Failed to read %s: %s. Ignoring", self._source_file, exc)
|
||||||
|
await self._move_file(self._source_file)
|
||||||
|
return
|
||||||
|
|
||||||
|
if items is None:
|
||||||
|
LOG.warning("No tasks found in %s; skipping migration.", self._source_file)
|
||||||
|
await self._move_file(self._source_file)
|
||||||
|
return
|
||||||
|
|
||||||
|
inserted = 0
|
||||||
|
seen_names: dict[str, int] = {}
|
||||||
|
for index, item in enumerate(items):
|
||||||
|
if not (normalized := self._normalize(item, index, seen_names)):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
await self._repo.create(normalized)
|
||||||
|
inserted += 1
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.exception("Failed to insert task '%s': %s", normalized["name"], exc)
|
||||||
|
|
||||||
|
LOG.info("Migrated %s task(s) from %s.", inserted, self._source_file)
|
||||||
|
await self._move_file(self._source_file)
|
||||||
|
|
||||||
|
def _normalize(self, item: Any, index: int, seen_names: dict[str, int]) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
LOG.warning("Skipping task at index %s due to invalid type.", index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
assert isinstance(item, dict)
|
||||||
|
|
||||||
|
name: str | None = item.get("name")
|
||||||
|
if not name or not isinstance(name, str):
|
||||||
|
LOG.warning("Skipping task at index %s due to missing name.", index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
normalized_name = name.strip()
|
||||||
|
if not normalized_name:
|
||||||
|
LOG.warning("Skipping task at index %s due to empty name.", index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = self._unique_name(normalized_name, seen_names)
|
||||||
|
|
||||||
|
url: str | None = item.get("url")
|
||||||
|
if not url or not isinstance(url, str):
|
||||||
|
LOG.warning("Skipping task '%s' at index %s due to missing URL.", name, index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
url = url.strip()
|
||||||
|
if not url:
|
||||||
|
LOG.warning("Skipping task '%s' at index %s due to empty URL.", name, index)
|
||||||
|
return None
|
||||||
|
|
||||||
|
folder: str = item.get("folder") if isinstance(item.get("folder"), str) else ""
|
||||||
|
preset: str = item.get("preset") if isinstance(item.get("preset"), str) else ""
|
||||||
|
timer: str = item.get("timer") if isinstance(item.get("timer"), str) else ""
|
||||||
|
template: str = item.get("template") if isinstance(item.get("template"), str) else ""
|
||||||
|
cli: str = item.get("cli") if isinstance(item.get("cli"), str) else ""
|
||||||
|
auto_start: bool = item.get("auto_start") if isinstance(item.get("auto_start"), bool) else True
|
||||||
|
handler_enabled: bool = item.get("handler_enabled") if isinstance(item.get("handler_enabled"), bool) else True
|
||||||
|
enabled: bool = item.get("enabled") if isinstance(item.get("enabled"), bool) else True
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated = Task(
|
||||||
|
name=name,
|
||||||
|
url=url,
|
||||||
|
folder=folder,
|
||||||
|
preset=preset,
|
||||||
|
timer=timer,
|
||||||
|
template=template,
|
||||||
|
cli=cli,
|
||||||
|
auto_start=auto_start,
|
||||||
|
handler_enabled=handler_enabled,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
return validated.model_dump()
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning("Skipping task '%s' at index %s due to validation error: %s", name, index, e)
|
||||||
|
return None
|
||||||
31
app/features/tasks/models.py
Normal file
31
app/features/tasks/models.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime # noqa: TC003
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Index, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.features.core.models import Base, UTCDateTime, utcnow
|
||||||
|
|
||||||
|
|
||||||
|
class TaskModel(Base):
|
||||||
|
__tablename__: str = "tasks"
|
||||||
|
__table_args__: tuple[Index, ...] = (
|
||||||
|
Index("ix_tasks_name", "name"),
|
||||||
|
Index("ix_tasks_enabled", "enabled"),
|
||||||
|
Index("ix_tasks_timer", "timer"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||||
|
url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
|
folder: Mapped[str] = mapped_column(String(512), nullable=False, default="")
|
||||||
|
preset: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
timer: Mapped[str] = mapped_column(String(255), nullable=False, default="")
|
||||||
|
template: Mapped[str] = mapped_column(String(1024), nullable=False, default="")
|
||||||
|
cli: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||||
|
auto_start: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
handler_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(UTCDateTime, default=utcnow, onupdate=utcnow, nullable=False)
|
||||||
169
app/features/tasks/repository.py
Normal file
169
app/features/tasks/repository.py
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy import func, or_, select
|
||||||
|
|
||||||
|
from app.features.core.deps import get_session
|
||||||
|
from app.features.tasks.models import TaskModel
|
||||||
|
from app.library.Singleton import Singleton
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.engine.result import Result
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.sql.elements import ColumnElement
|
||||||
|
from sqlalchemy.sql.selectable import Select
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class TasksRepository(metaclass=Singleton):
|
||||||
|
def __init__(self, session: AsyncGenerator[AsyncSession] | None = None) -> None:
|
||||||
|
self._migrated = False
|
||||||
|
self.session = session or get_session
|
||||||
|
|
||||||
|
async def run_migrations(self) -> None:
|
||||||
|
if self._migrated:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._migrated = True
|
||||||
|
from app.features.tasks.migration import Migration
|
||||||
|
|
||||||
|
await Migration(repo=self).run()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_instance() -> TasksRepository:
|
||||||
|
return TasksRepository()
|
||||||
|
|
||||||
|
async def list(self) -> list[TaskModel]:
|
||||||
|
async with self.session() as session:
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).order_by(TaskModel.name.asc()))
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def list_paginated(self, page: int, per_page: int) -> tuple[list[TaskModel], int, int, int]:
|
||||||
|
async with self.session() as session:
|
||||||
|
total: int = await self.count()
|
||||||
|
total_pages: int = (total + per_page - 1) // per_page if total > 0 else 1
|
||||||
|
|
||||||
|
if page > total_pages and total > 0:
|
||||||
|
page = total_pages
|
||||||
|
|
||||||
|
query: Select[tuple[TaskModel]] = (
|
||||||
|
select(TaskModel).order_by(TaskModel.name.asc()).limit(per_page).offset((page - 1) * per_page)
|
||||||
|
)
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(query)
|
||||||
|
return list(result.scalars().all()), total, page, total_pages
|
||||||
|
|
||||||
|
async def count(self) -> int:
|
||||||
|
async with self.session() as session:
|
||||||
|
result: Result[tuple[int]] = await session.execute(select(func.count()).select_from(TaskModel))
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def get(self, identifier: int | str) -> TaskModel | None:
|
||||||
|
async with self.session() as session:
|
||||||
|
if not identifier:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(identifier, int):
|
||||||
|
clause: ColumnElement[bool] = TaskModel.id == identifier
|
||||||
|
elif isinstance(identifier, str) and identifier.isdigit():
|
||||||
|
clause = or_(TaskModel.id == int(identifier), TaskModel.name == identifier)
|
||||||
|
else:
|
||||||
|
clause = TaskModel.name == identifier
|
||||||
|
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).where(clause).limit(1))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_by_name(self, name: str, exclude_id: int | None = None) -> TaskModel | None:
|
||||||
|
async with self.session() as session:
|
||||||
|
query: Select[tuple[TaskModel]] = select(TaskModel).where(TaskModel.name == name)
|
||||||
|
if exclude_id is not None:
|
||||||
|
query = query.where(TaskModel.id != exclude_id)
|
||||||
|
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(query.limit(1))
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_all_enabled(self) -> list[TaskModel]:
|
||||||
|
"""Get all enabled tasks."""
|
||||||
|
async with self.session() as session:
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(
|
||||||
|
select(TaskModel).where(TaskModel.enabled == True).order_by(TaskModel.name.asc()) # noqa: E712
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def get_all_with_timer(self) -> list[TaskModel]:
|
||||||
|
"""Get all tasks that have a timer configured."""
|
||||||
|
async with self.session() as session:
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(
|
||||||
|
select(TaskModel).where(TaskModel.timer != "").order_by(TaskModel.name.asc())
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def create(self, payload: TaskModel | dict) -> TaskModel:
|
||||||
|
async with self.session() as session:
|
||||||
|
model: TaskModel = TaskModel(**payload) if isinstance(payload, dict) else payload
|
||||||
|
if model.id is not None:
|
||||||
|
model.id = None
|
||||||
|
|
||||||
|
if await self.get_by_name(name=model.name) is not None:
|
||||||
|
msg: str = f"Task with name '{model.name}' already exists."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
session.add(model)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
async def update(self, identifier: int | str, payload: dict[str, Any]) -> TaskModel:
|
||||||
|
"""Update an existing task."""
|
||||||
|
async with self.session() as session:
|
||||||
|
if isinstance(identifier, int):
|
||||||
|
clause: ColumnElement[bool] = TaskModel.id == identifier
|
||||||
|
elif isinstance(identifier, str) and identifier.isdigit():
|
||||||
|
clause = or_(TaskModel.id == int(identifier), TaskModel.name == identifier)
|
||||||
|
else:
|
||||||
|
clause = TaskModel.name == identifier
|
||||||
|
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).where(clause).limit(1))
|
||||||
|
model: TaskModel | None = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
msg: str = f"Task '{identifier}' not found."
|
||||||
|
raise KeyError(msg)
|
||||||
|
|
||||||
|
if "name" in payload:
|
||||||
|
existing: TaskModel | None = await self.get_by_name(name=payload["name"], exclude_id=model.id)
|
||||||
|
if existing is not None:
|
||||||
|
msg = f"Task with name '{payload['name']}' already exists."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
for key, value in payload.items():
|
||||||
|
if hasattr(model, key):
|
||||||
|
setattr(model, key, value)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(model)
|
||||||
|
return model
|
||||||
|
|
||||||
|
async def delete(self, identifier: int | str) -> TaskModel:
|
||||||
|
async with self.session() as session:
|
||||||
|
if isinstance(identifier, int):
|
||||||
|
clause: ColumnElement[bool] = TaskModel.id == identifier
|
||||||
|
elif isinstance(identifier, str) and identifier.isdigit():
|
||||||
|
clause = or_(TaskModel.id == int(identifier), TaskModel.name == identifier)
|
||||||
|
else:
|
||||||
|
clause = TaskModel.name == identifier
|
||||||
|
|
||||||
|
result: Result[tuple[TaskModel]] = await session.execute(select(TaskModel).where(clause).limit(1))
|
||||||
|
model: TaskModel | None = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
msg: str = f"Task '{identifier}' not found."
|
||||||
|
raise KeyError(msg)
|
||||||
|
|
||||||
|
await session.delete(model)
|
||||||
|
await session.commit()
|
||||||
|
return model
|
||||||
480
app/features/tasks/router.py
Normal file
480
app/features/tasks/router.py
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web import Request, Response
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent, Pagination
|
||||||
|
from app.features.core.utils import build_pagination, format_validation_errors, normalize_pagination
|
||||||
|
from app.features.tasks.definitions.results import HandleTask as ExtendedTask
|
||||||
|
from app.features.tasks.definitions.results import TaskFailure, TaskResult
|
||||||
|
from app.features.tasks.definitions.service import TaskHandle
|
||||||
|
from app.features.tasks.repository import TasksRepository
|
||||||
|
from app.features.tasks.schemas import Task, TaskList, TaskPatch
|
||||||
|
from app.library.ag_utils import ag
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.encoder import Encoder
|
||||||
|
from app.library.Events import EventBus, Events
|
||||||
|
from app.library.router import route
|
||||||
|
from app.library.Utils import get_channel_images, get_file, parse_outtmpl, validate_url
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _model(model: Any) -> Task:
|
||||||
|
return Task.model_validate(model)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(model: Any) -> dict:
|
||||||
|
return _model(model).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@route("GET", "api/tasks/", "tasks_list")
|
||||||
|
async def tasks_list(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||||
|
page, per_page = normalize_pagination(request)
|
||||||
|
items, total, current_page, total_pages = await repo.list_paginated(page, per_page)
|
||||||
|
return web.json_response(
|
||||||
|
data=TaskList(
|
||||||
|
items=[_model(model) for model in items],
|
||||||
|
pagination=Pagination.model_validate(build_pagination(total, current_page, per_page, total_pages)),
|
||||||
|
),
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("POST", "api/tasks/", "tasks_add")
|
||||||
|
async def tasks_add(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Invalid request body expecting dict."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
item: Task = Task.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if await repo.get_by_name(item.name):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task with name '{item.name}' already exists."},
|
||||||
|
status=web.HTTPConflict.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
created = await repo.create(item.model_dump())
|
||||||
|
saved = _serialize(created)
|
||||||
|
except ValueError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.CREATE, data=saved))
|
||||||
|
|
||||||
|
return web.json_response(data=saved, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
|
||||||
|
@route("GET", r"api/tasks/{id:\d+}", "tasks_get")
|
||||||
|
async def tasks_get(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||||
|
if not (identifier := request.match_info.get("id")):
|
||||||
|
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
model = await repo.get(identifier)
|
||||||
|
if not model:
|
||||||
|
return web.json_response({"error": "Task not found"}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
return web.json_response(data=_serialize(model), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
|
||||||
|
@route("DELETE", r"api/tasks/{id:\d+}", "tasks_delete")
|
||||||
|
async def tasks_delete(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||||
|
if not (identifier := request.match_info.get("id")):
|
||||||
|
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deleted = _serialize(await repo.delete(identifier))
|
||||||
|
notify.emit(
|
||||||
|
Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.DELETE, data=deleted)
|
||||||
|
)
|
||||||
|
return web.json_response(
|
||||||
|
data=deleted,
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=encoder.encode,
|
||||||
|
)
|
||||||
|
except KeyError as exc:
|
||||||
|
return web.json_response({"error": str(exc)}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@route("PATCH", r"api/tasks/{id:\d+}", "tasks_patch")
|
||||||
|
async def tasks_patch(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||||
|
if not (identifier := request.match_info.get("id")):
|
||||||
|
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
model = await repo.get(identifier)
|
||||||
|
if not model:
|
||||||
|
return web.json_response({"error": "Task not found"}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Invalid request body expecting dict."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated = TaskPatch.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task with name '{validated.name}' already exists."},
|
||||||
|
status=web.HTTPConflict.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||||
|
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
|
||||||
|
return web.json_response(
|
||||||
|
data=updated,
|
||||||
|
status=web.HTTPOk.status_code,
|
||||||
|
dumps=encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("PUT", r"api/tasks/{id:\d+}", "tasks_update")
|
||||||
|
async def tasks_update(request: Request, repo: TasksRepository, encoder: Encoder, notify: EventBus) -> Response:
|
||||||
|
if not (identifier := request.match_info.get("id")):
|
||||||
|
return web.json_response({"error": "ID required"}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
model = await repo.get(identifier)
|
||||||
|
if not model:
|
||||||
|
return web.json_response({"error": "Task not found"}, status=web.HTTPNotFound.status_code)
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Invalid request body expecting dict."},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
validated = Task.model_validate(data)
|
||||||
|
except ValidationError as exc:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Failed to validate task.", "detail": format_validation_errors(exc)},
|
||||||
|
status=web.HTTPBadRequest.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
if validated.name and await repo.get_by_name(validated.name, exclude_id=model.id):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task with name '{validated.name}' already exists."},
|
||||||
|
status=web.HTTPConflict.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = _serialize(await repo.update(model.id, validated.model_dump(exclude_unset=True)))
|
||||||
|
notify.emit(Events.CONFIG_UPDATE, data=ConfigEvent(feature=CEFeature.TASKS, action=CEAction.UPDATE, data=updated))
|
||||||
|
|
||||||
|
return web.json_response(data=updated, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
|
||||||
|
|
||||||
|
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
||||||
|
async def task_handler_inspect(request: Request, handler: TaskHandle, encoder: Encoder, config: Config) -> Response:
|
||||||
|
"""
|
||||||
|
Check if handler can process the given URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The request object.
|
||||||
|
handler: The handler service instance.
|
||||||
|
encoder: The encoder instance.
|
||||||
|
config: The config instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
url: str | None = data.get("url") if isinstance(data, dict) else None
|
||||||
|
if not url:
|
||||||
|
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
||||||
|
if not static_only:
|
||||||
|
try:
|
||||||
|
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
||||||
|
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
result: TaskResult | TaskFailure = await handler.inspect(
|
||||||
|
url=url, preset=preset, handler_name=handler_name, static_only=static_only
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
return web.json_response(
|
||||||
|
{"error": "Failed to inspect handler.", "message": str(e)},
|
||||||
|
status=web.HTTPInternalServerError.status_code,
|
||||||
|
)
|
||||||
|
|
||||||
|
return web.json_response(
|
||||||
|
data=result,
|
||||||
|
status=web.HTTPBadRequest.status_code if isinstance(result, TaskFailure) else web.HTTPOk.status_code,
|
||||||
|
dumps=encoder.encode,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@route("POST", r"api/tasks/{id:\d+}/mark", "tasks_mark")
|
||||||
|
async def task_mark(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||||
|
"""
|
||||||
|
Mark all items from task as downloaded.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The request object.
|
||||||
|
repo: The tasks repository instance.
|
||||||
|
encoder: The encoder instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not (task_id := request.match_info.get("id")):
|
||||||
|
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = await repo.get(int(task_id))
|
||||||
|
if not model:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to extended Task with handler methods
|
||||||
|
task = ExtendedTask.model_validate(model)
|
||||||
|
_status, _message = await task.mark()
|
||||||
|
|
||||||
|
if not _status:
|
||||||
|
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@route("DELETE", r"api/tasks/{id:\d+}/mark", "tasks_unmark")
|
||||||
|
async def task_unmark(request: Request, repo: TasksRepository, encoder: Encoder) -> Response:
|
||||||
|
"""
|
||||||
|
Remove all task items from download archive.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The request object.
|
||||||
|
repo: The tasks repository instance.
|
||||||
|
encoder: The encoder instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
if not (task_id := request.match_info.get("id")):
|
||||||
|
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
try:
|
||||||
|
model = await repo.get(int(task_id))
|
||||||
|
if not model:
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to extended Task with handler methods
|
||||||
|
task = ExtendedTask.model_validate(model)
|
||||||
|
_status, _message = await task.unmark()
|
||||||
|
|
||||||
|
if not _status:
|
||||||
|
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
@route("POST", r"api/tasks/{id:\d+}/metadata", "tasks_metadata")
|
||||||
|
async def task_metadata(request: Request, repo: TasksRepository, config: Config, encoder: Encoder) -> Response:
|
||||||
|
"""
|
||||||
|
Generate metadata for the task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: The request object.
|
||||||
|
repo: The tasks repository instance.
|
||||||
|
config: The config instance.
|
||||||
|
encoder: The encoder instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The response object.
|
||||||
|
|
||||||
|
"""
|
||||||
|
task_id = request.match_info.get("id")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not (model := await repo.get(int(task_id))):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to extended Task with handler methods
|
||||||
|
task = ExtendedTask.model_validate(model)
|
||||||
|
|
||||||
|
(save_path, _) = get_file(config.download_path, task.folder)
|
||||||
|
if not str(save_path or "").startswith(str(config.download_path)):
|
||||||
|
return web.json_response(data={"error": "Invalid task folder."}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
if not save_path.exists():
|
||||||
|
save_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
metadata, status, message = await task.fetch_metadata()
|
||||||
|
if not status:
|
||||||
|
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
|
||||||
|
|
||||||
|
if not task.folder:
|
||||||
|
try:
|
||||||
|
ytdlp_opts: dict = task.get_ytdlp_opts().get_all()
|
||||||
|
outtmpl: str = parse_outtmpl(
|
||||||
|
output_template=ytdlp_opts.get("outtmpl", {}).get("default", "{title} [{id}]"),
|
||||||
|
info_dict=metadata,
|
||||||
|
params=ytdlp_opts,
|
||||||
|
)
|
||||||
|
if outtmpl:
|
||||||
|
_path: Path = save_path / outtmpl
|
||||||
|
if not _path.is_dir():
|
||||||
|
_path: Path = _path.parent
|
||||||
|
|
||||||
|
(save_path, _) = get_file(config.download_path, _path.relative_to(config.download_path))
|
||||||
|
if not str(save_path or "").startswith(str(config.download_path)):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Invalid final path folder."}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
if not save_path.exists():
|
||||||
|
save_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"id": ag(metadata, ["id", "channel_id"]),
|
||||||
|
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
|
||||||
|
"title": ag(metadata, ["title", "fulltitle"]) or None,
|
||||||
|
"description": metadata.get("description", ""),
|
||||||
|
"uploader": metadata.get("uploader", ""),
|
||||||
|
"tags": metadata.get("tags", []),
|
||||||
|
"year": metadata.get("release_year"),
|
||||||
|
"thumbnails": get_channel_images(metadata.get("thumbnails", {})),
|
||||||
|
}
|
||||||
|
|
||||||
|
if not info.get("title"):
|
||||||
|
return web.json_response(
|
||||||
|
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
LOG.info(f"Generating metadata for task '{task.name}' in '{save_path!s}'")
|
||||||
|
|
||||||
|
from yt_dlp.utils import sanitize_filename
|
||||||
|
|
||||||
|
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
||||||
|
|
||||||
|
title: str = sanitize_filename(info.get("title"))
|
||||||
|
info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
|
||||||
|
info_file.write_text(encoder.encode(metadata), encoding="utf-8")
|
||||||
|
info["json_file"] = str(info_file.relative_to(config.download_path))
|
||||||
|
|
||||||
|
xml_file: Path = save_path / "tvshow.nfo"
|
||||||
|
info["nfo_file"] = str(xml_file.relative_to(config.download_path))
|
||||||
|
|
||||||
|
xml_content = "<tvshow>\n"
|
||||||
|
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
|
||||||
|
if info.get("description"):
|
||||||
|
xml_content += (
|
||||||
|
f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}</plot>\n"
|
||||||
|
)
|
||||||
|
if info.get("id"):
|
||||||
|
xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n"
|
||||||
|
if info.get("id_type") and info.get("id"):
|
||||||
|
xml_content += f' <uniqueid type="{NFOMakerPP._escape_text(info.get("id_type"))}" default="true">{NFOMakerPP._escape_text(info.get("id"))}</uniqueid>\n'
|
||||||
|
if info.get("uploader"):
|
||||||
|
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
|
||||||
|
if info.get("tags", []):
|
||||||
|
for tag in info.get("tags", []):
|
||||||
|
xml_content += f" <tag>{NFOMakerPP._escape_text(tag)}</tag>\n"
|
||||||
|
if info.get("year"):
|
||||||
|
xml_content += f" <year>{info.get('year')}</year>\n"
|
||||||
|
xml_content += " <status>Continuing</status>\n"
|
||||||
|
xml_content += "</tvshow>\n"
|
||||||
|
xml_file.write_text(xml_content, encoding="utf-8")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from yt_dlp.utils.networking import random_user_agent
|
||||||
|
|
||||||
|
from app.library.httpx_client import async_client
|
||||||
|
|
||||||
|
ytdlp_args: dict = task.get_ytdlp_opts().get_all()
|
||||||
|
opts: dict[str, Any] = {
|
||||||
|
"headers": {
|
||||||
|
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if proxy := ytdlp_args.get("proxy"):
|
||||||
|
opts["proxy"] = proxy
|
||||||
|
|
||||||
|
try:
|
||||||
|
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
|
||||||
|
|
||||||
|
opts["transport"] = AsyncCurlTransport(
|
||||||
|
impersonate="chrome",
|
||||||
|
default_headers=True,
|
||||||
|
curl_options={CurlOpt.FRESH_CONNECT: True},
|
||||||
|
)
|
||||||
|
opts.pop("headers", None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async with async_client(**opts) as client:
|
||||||
|
for key in info.get("thumbnails", {}):
|
||||||
|
try:
|
||||||
|
url = info["thumbnails"][key]
|
||||||
|
LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_url(url, allow_internal=config.allow_internal_urls)
|
||||||
|
except ValueError:
|
||||||
|
LOG.warning(f"Invalid thumbnail url '{url}'")
|
||||||
|
continue
|
||||||
|
|
||||||
|
resp = await client.request(method="GET", url=url, follow_redirects=True)
|
||||||
|
|
||||||
|
img_file = save_path / f"{key}.jpg"
|
||||||
|
img_file.write_bytes(resp.content)
|
||||||
|
info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
|
||||||
|
continue
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
|
||||||
|
|
||||||
|
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
||||||
|
except ValueError as e:
|
||||||
|
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
||||||
131
app/features/tasks/schemas.py
Normal file
131
app/features/tasks/schemas.py
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime # noqa: TC003
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from app.features.core.schemas import Pagination
|
||||||
|
|
||||||
|
|
||||||
|
class Task(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True, str_strip_whitespace=True)
|
||||||
|
|
||||||
|
id: int | None = None
|
||||||
|
name: str = Field(min_length=1)
|
||||||
|
url: str = Field(min_length=1)
|
||||||
|
folder: str = ""
|
||||||
|
preset: str = ""
|
||||||
|
timer: str = ""
|
||||||
|
template: str = ""
|
||||||
|
cli: str = ""
|
||||||
|
auto_start: bool = True
|
||||||
|
handler_enabled: bool = True
|
||||||
|
enabled: bool = True
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
@field_validator("name", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_name(cls, value: Any) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
msg: str = "Name must be a string."
|
||||||
|
raise ValueError(msg)
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
msg = "Name cannot be empty."
|
||||||
|
raise ValueError(msg)
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("url", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_url(cls, value: Any) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
msg: str = "URL must be a string."
|
||||||
|
raise ValueError(msg)
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
msg = "URL cannot be empty."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
from app.library.Utils import validate_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
validate_url(value, allow_internal=True)
|
||||||
|
except ValueError as e:
|
||||||
|
msg = f"Invalid URL format: {e!s}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("timer", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validate_timer(cls, value: Any) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
msg: str = "Timer must be a string."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cronsim import CronSim
|
||||||
|
|
||||||
|
CronSim(value, datetime.now(UTC))
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Invalid timer format: {e!s}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
@field_validator("cli", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validate_cli(cls, value: Any) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
if not isinstance(value, str):
|
||||||
|
msg: str = "CLI must be a string."
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
from app.library.Utils import arg_converter
|
||||||
|
|
||||||
|
try:
|
||||||
|
arg_converter(args=value)
|
||||||
|
except Exception as e:
|
||||||
|
msg = f"Invalid command options for yt-dlp: {e!s}"
|
||||||
|
raise ValueError(msg) from e
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPatch(BaseModel):
|
||||||
|
model_config = ConfigDict(str_strip_whitespace=True)
|
||||||
|
|
||||||
|
name: str | None = None
|
||||||
|
url: str | None = None
|
||||||
|
folder: str | None = None
|
||||||
|
preset: str | None = None
|
||||||
|
timer: str | None = None
|
||||||
|
template: str | None = None
|
||||||
|
cli: str | None = None
|
||||||
|
auto_start: bool | None = None
|
||||||
|
handler_enabled: bool | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskList(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
items: list[Task] = Field(default_factory=list)
|
||||||
|
pagination: Pagination
|
||||||
185
app/features/tasks/service.py
Normal file
185
app/features/tasks/service.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.features.core.schemas import CEAction, CEFeature, ConfigEvent
|
||||||
|
from app.features.tasks.models import TaskModel
|
||||||
|
from app.features.tasks.utils import cron_time
|
||||||
|
from app.library.Events import Event, EventBus, Events
|
||||||
|
from app.library.Scheduler import Scheduler
|
||||||
|
from app.library.Services import Services
|
||||||
|
from app.library.Singleton import Singleton
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Tasks(metaclass=Singleton):
|
||||||
|
def __init__(self):
|
||||||
|
from app.features.tasks.deps import get_tasks_repo
|
||||||
|
|
||||||
|
self._repo = get_tasks_repo()
|
||||||
|
self._loaded: bool = False
|
||||||
|
self._handlers_service = None
|
||||||
|
self._scheduler = Scheduler.get_instance()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_instance() -> Tasks:
|
||||||
|
"""Get the singleton instance of Tasks."""
|
||||||
|
return Tasks()
|
||||||
|
|
||||||
|
def attach(self, _: web.Application) -> None:
|
||||||
|
async def handle_started(_, __):
|
||||||
|
await self._repo.run_migrations()
|
||||||
|
await self._load_tasks()
|
||||||
|
await self._init_handlers_service(self._scheduler)
|
||||||
|
|
||||||
|
Services.get_instance().add("tasks_service", self).add("tasks_repository", self._repo)
|
||||||
|
|
||||||
|
async def handle_config_update(e: Event, _):
|
||||||
|
if isinstance(e.data, ConfigEvent) and CEFeature.TASKS == e.data.feature:
|
||||||
|
await self._handle_task_change(e.data)
|
||||||
|
|
||||||
|
EventBus.get_instance().subscribe(
|
||||||
|
Events.CONFIG_UPDATE, handle_config_update, "Tasks.config_update_scheduler"
|
||||||
|
).subscribe(Events.STARTED, handle_started, "TasksRepository.run_migrations")
|
||||||
|
|
||||||
|
async def on_shutdown(self, _: web.Application) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _load_tasks(self) -> None:
|
||||||
|
tasks = await self._repo.list()
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
if not task.timer or not task.enabled:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=f"task-cronjob-{task.id}")
|
||||||
|
LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
|
||||||
|
except Exception as e:
|
||||||
|
LOG.exception(e)
|
||||||
|
LOG.error(f"Failed to queue task '{task.name}'. '{e!s}'.")
|
||||||
|
|
||||||
|
async def _init_handlers_service(self, scheduler) -> None:
|
||||||
|
"""Initialize the handlers service after migrations."""
|
||||||
|
if self._handlers_service is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.features.tasks.definitions.service import TaskHandle
|
||||||
|
from app.library.config import Config
|
||||||
|
|
||||||
|
config = Config.get_instance()
|
||||||
|
self._handlers_service = TaskHandle(scheduler, self._repo, config)
|
||||||
|
self._handlers_service.load()
|
||||||
|
LOG.debug("Task handlers service initialized.")
|
||||||
|
Services.get_instance().add("task_handle_service", self._handlers_service)
|
||||||
|
|
||||||
|
async def _handle_task_change(self, event_data) -> None:
|
||||||
|
task_data: dict = event_data.data
|
||||||
|
task_id: str = f"task-cronjob-{task_data['id']}"
|
||||||
|
|
||||||
|
if CEAction.DELETE == event_data.action:
|
||||||
|
if self._scheduler.has(task_id):
|
||||||
|
self._scheduler.remove(task_id)
|
||||||
|
|
||||||
|
elif event_data.action in (CEAction.CREATE, CEAction.UPDATE):
|
||||||
|
if not (task := await self._repo.get(int(task_data["id"]))):
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._scheduler.has(task_id):
|
||||||
|
self._scheduler.remove(task_id)
|
||||||
|
|
||||||
|
if task.timer and task.enabled:
|
||||||
|
self._scheduler.add(timer=task.timer, func=self._runner, args=(task,), id=task_id)
|
||||||
|
LOG.info(f"Task '{task.id}: {task.name}' queued to be executed '{cron_time(task.timer)}'.")
|
||||||
|
|
||||||
|
async def _runner(self, task: TaskModel) -> None:
|
||||||
|
"""
|
||||||
|
Execute a scheduled task.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task: The TaskModel to execute.
|
||||||
|
|
||||||
|
"""
|
||||||
|
import time
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from app.library.config import Config
|
||||||
|
from app.library.downloads import DownloadQueue
|
||||||
|
from app.library.ItemDTO import Item
|
||||||
|
|
||||||
|
timeNow: str = datetime.now(UTC).isoformat()
|
||||||
|
try:
|
||||||
|
if not (task := await self._repo.get(task.id)):
|
||||||
|
LOG.info(f"Task '{task.name}' no longer exists.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not task.enabled:
|
||||||
|
LOG.debug(f"Task '{task.name}' is disabled. Skipping execution.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not task.url:
|
||||||
|
LOG.error(f"Failed to dispatch '{task.name}'. No URL found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
started: float = time.time()
|
||||||
|
|
||||||
|
config = Config.get_instance()
|
||||||
|
preset: str = task.preset or config.default_preset
|
||||||
|
folder: str = task.folder or ""
|
||||||
|
template: str = task.template or ""
|
||||||
|
cli: str = task.cli or ""
|
||||||
|
|
||||||
|
notify: EventBus = EventBus.get_instance()
|
||||||
|
|
||||||
|
status = await DownloadQueue.get_instance().add(
|
||||||
|
item=Item.format(
|
||||||
|
{
|
||||||
|
"url": task.url,
|
||||||
|
"preset": preset,
|
||||||
|
"folder": folder,
|
||||||
|
"template": template,
|
||||||
|
"cli": cli,
|
||||||
|
"auto_start": task.auto_start,
|
||||||
|
"extras": {
|
||||||
|
"source_name": task.name,
|
||||||
|
"source_id": str(task.id),
|
||||||
|
"source_handler": "Tasks",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
timeNow = datetime.now(UTC).isoformat()
|
||||||
|
ended: float = time.time()
|
||||||
|
LOG.info(f"Task '{task.name}' completed at '{timeNow}' took '{ended - started:.2f}' seconds.")
|
||||||
|
|
||||||
|
notify.emit(
|
||||||
|
Events.TASK_DISPATCHED,
|
||||||
|
data={**status, "preset": task.preset} if status else {"preset": task.preset},
|
||||||
|
title=f"Task '{task.name}' dispatched",
|
||||||
|
message=f"Task '{task.name}' dispatched at '{timeNow}'.",
|
||||||
|
)
|
||||||
|
notify.emit(
|
||||||
|
Events.LOG_SUCCESS,
|
||||||
|
data={"preset": task.preset, "lowPriority": True},
|
||||||
|
title="Task completed",
|
||||||
|
message=f"Task '{task.name}' completed in '{ended - started:.2f}'.",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
LOG.error(f"Failed to execute '{task.name}' at '{timeNow}'. '{e!s}'.")
|
||||||
|
EventBus.get_instance().emit(
|
||||||
|
Events.LOG_ERROR,
|
||||||
|
data={"preset": task.preset},
|
||||||
|
title="Task failed",
|
||||||
|
message=f"Failed to execute '{task.name}'. '{e!s}'",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def handlers(self):
|
||||||
|
"""Get the handlers service instance."""
|
||||||
|
return self._handlers_service
|
||||||
1
app/features/tasks/tests/__init__.py
Normal file
1
app/features/tasks/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for tasks feature."""
|
||||||
289
app/features/tasks/tests/test_tasks_repository.py
Normal file
289
app/features/tasks/tests/test_tasks_repository.py
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
"""Tests for TasksRepository."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
from app.features.tasks.models import TaskModel
|
||||||
|
from app.features.tasks.repository import TasksRepository
|
||||||
|
from app.library.sqlite_store import SqliteStore
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def repo(tmp_path):
|
||||||
|
"""Provide a fresh repository instance with initialized database for each test."""
|
||||||
|
TasksRepository._reset_singleton()
|
||||||
|
SqliteStore._reset_singleton()
|
||||||
|
|
||||||
|
store = SqliteStore(db_path=str(":memory:"))
|
||||||
|
await store.get_connection()
|
||||||
|
|
||||||
|
repository = TasksRepository.get_instance()
|
||||||
|
|
||||||
|
yield repository
|
||||||
|
|
||||||
|
if store._conn:
|
||||||
|
await store._conn.close()
|
||||||
|
if store._engine:
|
||||||
|
await store._engine.dispose()
|
||||||
|
|
||||||
|
TasksRepository._reset_singleton()
|
||||||
|
SqliteStore._reset_singleton()
|
||||||
|
|
||||||
|
|
||||||
|
class TestTasksRepository:
|
||||||
|
"""Test suite for TasksRepository database operations."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_repository_singleton(self, repo):
|
||||||
|
"""Verify repository follows singleton pattern."""
|
||||||
|
instance1 = TasksRepository.get_instance()
|
||||||
|
instance2 = TasksRepository.get_instance()
|
||||||
|
assert instance1 is instance2, "Should return same singleton instance"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_empty(self, repo):
|
||||||
|
"""List returns empty when no tasks exist."""
|
||||||
|
tasks = await repo.list()
|
||||||
|
assert tasks == [], "Should return empty list when no tasks"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_count_empty(self, repo):
|
||||||
|
"""Count returns 0 when no tasks exist."""
|
||||||
|
count = await repo.count()
|
||||||
|
assert count == 0, "Should return 0 when no tasks exist"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_task(self, repo):
|
||||||
|
"""Create task with valid data."""
|
||||||
|
data = {
|
||||||
|
"name": "Daily Download",
|
||||||
|
"url": "https://example.com/video",
|
||||||
|
"folder": "/downloads",
|
||||||
|
"preset": "audio",
|
||||||
|
"timer": "0 0 * * *",
|
||||||
|
"template": "%(title)s.%(ext)s",
|
||||||
|
"cli": "--format best",
|
||||||
|
"auto_start": True,
|
||||||
|
"handler_enabled": True,
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
model = await repo.create(data)
|
||||||
|
|
||||||
|
assert model.id is not None, "Should generate ID for new task"
|
||||||
|
assert model.name == "Daily Download", "Should store name correctly"
|
||||||
|
assert model.url == "https://example.com/video", "Should store URL correctly"
|
||||||
|
assert model.folder == "/downloads", "Should store folder correctly"
|
||||||
|
assert model.preset == "audio", "Should store preset correctly"
|
||||||
|
assert model.timer == "0 0 * * *", "Should store timer correctly"
|
||||||
|
assert model.template == "%(title)s.%(ext)s", "Should store template correctly"
|
||||||
|
assert model.cli == "--format best", "Should store CLI correctly"
|
||||||
|
assert model.auto_start is True, "Should store auto_start correctly"
|
||||||
|
assert model.handler_enabled is True, "Should store handler_enabled correctly"
|
||||||
|
assert model.enabled is True, "Should store enabled correctly"
|
||||||
|
assert model.created_at is not None, "Should have created_at timestamp"
|
||||||
|
assert model.updated_at is not None, "Should have updated_at timestamp"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_with_minimal_data(self, repo):
|
||||||
|
"""Create task with minimal required data."""
|
||||||
|
data = {
|
||||||
|
"name": "Simple Task",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
model = await repo.create(data)
|
||||||
|
|
||||||
|
assert model.name == "Simple Task", "Should store name"
|
||||||
|
assert model.url == "https://example.com", "Should store URL"
|
||||||
|
assert model.folder == "", "Should default folder to empty string"
|
||||||
|
assert model.preset == "", "Should default preset to empty string"
|
||||||
|
assert model.timer == "", "Should default timer to empty string"
|
||||||
|
assert model.template == "", "Should default template to empty string"
|
||||||
|
assert model.cli == "", "Should default CLI to empty string"
|
||||||
|
assert model.auto_start is True, "Should default auto_start to True"
|
||||||
|
assert model.handler_enabled is True, "Should default handler_enabled to True"
|
||||||
|
assert model.enabled is True, "Should default enabled to True"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_id(self, repo):
|
||||||
|
"""Get task by integer ID."""
|
||||||
|
created = await repo.create(
|
||||||
|
{
|
||||||
|
"name": "Get Test",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved = await repo.get(created.id)
|
||||||
|
|
||||||
|
assert retrieved is not None, "Should retrieve created task"
|
||||||
|
assert retrieved.id == created.id, "Should retrieve correct task by ID"
|
||||||
|
assert retrieved.name == "Get Test", "Should match created task name"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_string_id(self, repo):
|
||||||
|
"""Get task by string ID."""
|
||||||
|
created = await repo.create(
|
||||||
|
{
|
||||||
|
"name": "String ID Test",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved = await repo.get(str(created.id))
|
||||||
|
|
||||||
|
assert retrieved is not None, "Should retrieve by string ID"
|
||||||
|
assert retrieved.id == created.id, "Should match created task ID"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_nonexistent(self, repo):
|
||||||
|
"""Get nonexistent task returns None."""
|
||||||
|
result = await repo.get(99999)
|
||||||
|
assert result is None, "Should return None for nonexistent ID"
|
||||||
|
|
||||||
|
result = await repo.get("99999")
|
||||||
|
assert result is None, "Should return None for nonexistent string ID"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_task(self, repo):
|
||||||
|
"""Update existing task."""
|
||||||
|
created = await repo.create(
|
||||||
|
{
|
||||||
|
"name": "Update Test",
|
||||||
|
"url": "https://example.com",
|
||||||
|
"preset": "video",
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = await repo.update(
|
||||||
|
created.id,
|
||||||
|
{
|
||||||
|
"name": "Updated Name",
|
||||||
|
"preset": "audio",
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert updated.name == "Updated Name", "Should update name"
|
||||||
|
assert updated.preset == "audio", "Should update preset"
|
||||||
|
assert updated.enabled is False, "Should update enabled"
|
||||||
|
assert updated.url == "https://example.com", "Should preserve unchanged URL"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_update_nonexistent_raises(self, repo):
|
||||||
|
"""Update nonexistent task raises KeyError."""
|
||||||
|
with pytest.raises(KeyError, match="not found"):
|
||||||
|
await repo.update(99999, {"name": "should_fail"})
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_task(self, repo):
|
||||||
|
"""Delete existing task."""
|
||||||
|
created = await repo.create(
|
||||||
|
{
|
||||||
|
"name": "Delete Test",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = await repo.delete(created.id)
|
||||||
|
|
||||||
|
assert deleted.id == created.id, "Should return deleted task"
|
||||||
|
|
||||||
|
result = await repo.get(created.id)
|
||||||
|
assert result is None, "Deleted task should not be retrievable"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_delete_nonexistent_raises(self, repo):
|
||||||
|
"""Delete nonexistent task raises KeyError."""
|
||||||
|
with pytest.raises(KeyError, match="not found"):
|
||||||
|
await repo.delete(99999)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_name(self, repo):
|
||||||
|
"""Get task by name."""
|
||||||
|
await repo.create(
|
||||||
|
{
|
||||||
|
"name": "Named Task",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
retrieved = await repo.get_by_name("Named Task")
|
||||||
|
|
||||||
|
assert retrieved is not None, "Should retrieve by name"
|
||||||
|
assert retrieved.name == "Named Task", "Should match task name"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_by_name_excludes_id(self, repo):
|
||||||
|
"""Get by name can exclude specific ID."""
|
||||||
|
first = await repo.create(
|
||||||
|
{
|
||||||
|
"name": "duplicate",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await repo.get_by_name("duplicate", exclude_id=first.id)
|
||||||
|
assert result is None, "Should not find when excluding only match"
|
||||||
|
|
||||||
|
result = await repo.get_by_name("duplicate", exclude_id=None)
|
||||||
|
assert result is not None, "Should find without exclusion"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_enabled(self, repo):
|
||||||
|
"""Get all enabled tasks."""
|
||||||
|
await repo.create({"name": "Enabled 1", "url": "https://example.com", "enabled": True})
|
||||||
|
await repo.create({"name": "Disabled", "url": "https://example.com", "enabled": False})
|
||||||
|
await repo.create({"name": "Enabled 2", "url": "https://example.com", "enabled": True})
|
||||||
|
|
||||||
|
enabled = await repo.get_all_enabled()
|
||||||
|
|
||||||
|
assert len(enabled) == 2, "Should return only enabled tasks"
|
||||||
|
assert all(task.enabled for task in enabled), "All tasks should be enabled"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_all_with_timer(self, repo):
|
||||||
|
"""Get all tasks with timer configured."""
|
||||||
|
await repo.create({"name": "With Timer", "url": "https://example.com", "timer": "0 0 * * *"})
|
||||||
|
await repo.create({"name": "No Timer", "url": "https://example.com", "timer": ""})
|
||||||
|
await repo.create({"name": "Another Timer", "url": "https://example.com", "timer": "0 12 * * *"})
|
||||||
|
|
||||||
|
with_timer = await repo.get_all_with_timer()
|
||||||
|
|
||||||
|
assert len(with_timer) == 2, "Should return only tasks with timer"
|
||||||
|
assert all(task.timer for task in with_timer), "All tasks should have timer"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_paginated(self, repo):
|
||||||
|
"""List paginated returns correct subset."""
|
||||||
|
for i in range(5):
|
||||||
|
await repo.create(
|
||||||
|
{
|
||||||
|
"name": f"Task {i}",
|
||||||
|
"url": "https://example.com",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
items, total, page, total_pages = await repo.list_paginated(page=1, per_page=2)
|
||||||
|
|
||||||
|
assert len(items) == 2, "Should return 2 items per page"
|
||||||
|
assert total == 5, "Should report total count of 5"
|
||||||
|
assert page == 1, "Should be on page 1"
|
||||||
|
assert total_pages == 3, "Should have 3 pages total"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_ordering(self, repo):
|
||||||
|
"""List orders by name ascending."""
|
||||||
|
await repo.create({"name": "Charlie", "url": "https://example.com"})
|
||||||
|
await repo.create({"name": "Alice", "url": "https://example.com"})
|
||||||
|
await repo.create({"name": "Bob", "url": "https://example.com"})
|
||||||
|
|
||||||
|
items = await repo.list()
|
||||||
|
|
||||||
|
assert items[0].name == "Alice", "Should be alphabetically first"
|
||||||
|
assert items[1].name == "Bob", "Should be alphabetically second"
|
||||||
|
assert items[2].name == "Charlie", "Should be alphabetically third"
|
||||||
16
app/features/tasks/utils.py
Normal file
16
app/features/tasks/utils.py
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cron_time(timer: str) -> str:
|
||||||
|
try:
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from cronsim import CronSim
|
||||||
|
|
||||||
|
cs = CronSim(timer, datetime.now(UTC))
|
||||||
|
return cs.explain()
|
||||||
|
except Exception as exc:
|
||||||
|
LOG.exception(exc)
|
||||||
|
return timer
|
||||||
|
|
@ -133,7 +133,7 @@ class Scheduler(metaclass=Singleton):
|
||||||
|
|
||||||
def remove(self, id: str | list[str]) -> bool:
|
def remove(self, id: str | list[str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Remove a job from the schedule.
|
Remove a job from the scheduler.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
id (str|list[str]): The id of the job to remove.
|
id (str|list[str]): The id of the job to remove.
|
||||||
|
|
@ -156,7 +156,7 @@ class Scheduler(metaclass=Singleton):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
del self._jobs[id]
|
del self._jobs[id]
|
||||||
LOG.debug(f"Removed job '{id}' from the schedule.")
|
LOG.debug(f"Removed job '{id}' from the scheduler.")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ class Services(metaclass=Singleton):
|
||||||
def get_instance() -> "Services":
|
def get_instance() -> "Services":
|
||||||
return Services()
|
return Services()
|
||||||
|
|
||||||
def add(self, name: str, service: Any, declared_type: type | None = None):
|
def add(self, name: str, service: Any, declared_type: type | None = None) -> "Services":
|
||||||
"""
|
"""
|
||||||
Add a service by name.
|
Add a service by name.
|
||||||
|
|
||||||
|
|
@ -57,12 +57,16 @@ class Services(metaclass=Singleton):
|
||||||
service: The service instance.
|
service: The service instance.
|
||||||
declared_type: The declared type of the service (optional).
|
declared_type: The declared type of the service (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Services: The Services instance (for chaining).
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if declared_type is None and service is not None:
|
if declared_type is None and service is not None:
|
||||||
declared_type = type(service)
|
declared_type = type(service)
|
||||||
|
|
||||||
self.remove(name)
|
self.remove(name)
|
||||||
self._services.append(ServiceEntry(name=name, declared_type=declared_type, instance=service))
|
self._services.append(ServiceEntry(name=name, declared_type=declared_type, instance=service))
|
||||||
|
return self
|
||||||
|
|
||||||
def add_all(self, services: dict[str, Any]):
|
def add_all(self, services: dict[str, Any]):
|
||||||
for name, svc in services.items():
|
for name, svc in services.items():
|
||||||
|
|
|
||||||
|
|
@ -1,435 +0,0 @@
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from aiohttp import web
|
|
||||||
from jsonschema import Draft7Validator, SchemaError, ValidationError
|
|
||||||
|
|
||||||
from .config import Config
|
|
||||||
from .encoder import Encoder
|
|
||||||
from .Services import Services
|
|
||||||
from .Singleton import Singleton
|
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger("task_definitions")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class TaskDefinitionRecord:
|
|
||||||
identifier: str
|
|
||||||
"""UUID identifier of the task definition."""
|
|
||||||
filename: str
|
|
||||||
"""Filename of the task definition JSON file."""
|
|
||||||
name: str
|
|
||||||
"""Human-readable name of the task definition."""
|
|
||||||
priority: int
|
|
||||||
"""Priority of the task definition."""
|
|
||||||
path: Path
|
|
||||||
"""Path to the task definition JSON file."""
|
|
||||||
data: dict[str, Any]
|
|
||||||
"""The task definition data."""
|
|
||||||
updated_at: float
|
|
||||||
"""Last modified timestamp of the task definition file."""
|
|
||||||
|
|
||||||
def serialize(self, *, include_definition: bool = False) -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Serialize the task definition record to a dictionary.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
include_definition (bool): Whether to include the full task definition data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict[str, Any]: The serialized task definition record.
|
|
||||||
|
|
||||||
"""
|
|
||||||
payload: dict[str, Any] = {
|
|
||||||
"id": self.identifier,
|
|
||||||
"name": self.name,
|
|
||||||
"priority": self.priority,
|
|
||||||
"updated_at": self.updated_at,
|
|
||||||
}
|
|
||||||
|
|
||||||
if include_definition:
|
|
||||||
payload["definition"] = self.data
|
|
||||||
|
|
||||||
return payload
|
|
||||||
|
|
||||||
def json(self, *, include_definition: bool = False) -> str:
|
|
||||||
"""
|
|
||||||
Serialize the task definition record to a JSON string.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
include_definition (bool): Whether to include the full task definition data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The JSON string representation of the task definition record.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return Encoder().encode(self.serialize(include_definition=include_definition))
|
|
||||||
|
|
||||||
|
|
||||||
class TaskDefinitions(metaclass=Singleton):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
directory: str | Path | None = None,
|
|
||||||
config: Config | None = None,
|
|
||||||
validator: Draft7Validator | None = None,
|
|
||||||
):
|
|
||||||
self._config: Config = config or Config.get_instance()
|
|
||||||
"Instance of Config to use."
|
|
||||||
self._directory: Path = Path(directory) if directory else Path(self._config.config_path) / "tasks"
|
|
||||||
"Directory where task definition files are stored."
|
|
||||||
self._validator: Draft7Validator | None = validator
|
|
||||||
"JSON schema validator instance."
|
|
||||||
self._items: dict[str, TaskDefinitionRecord] = {}
|
|
||||||
"Mapping of task definition ID to TaskDefinitionRecord."
|
|
||||||
self._schema: Path = Path(self._config.app_path) / "schema" / "task_definition.json"
|
|
||||||
"Path to the JSON schema file for task definitions."
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._directory.mkdir(parents=True, exist_ok=True)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Failed to create tasks directory '{self._directory}': {e}")
|
|
||||||
|
|
||||||
self.load()
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def get_instance(
|
|
||||||
directory: str | Path | None = None,
|
|
||||||
config: Config | None = None,
|
|
||||||
validator: Draft7Validator | None = None,
|
|
||||||
) -> "TaskDefinitions":
|
|
||||||
"""
|
|
||||||
Get the singleton instance of TaskDefinitions.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
directory (str | Path | None): Optional directory to store task definitions.
|
|
||||||
config (Config | None): Optional Config instance to use.
|
|
||||||
validator (Draft7Validator | None): Optional JSON schema validator to use.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TaskDefinitions: The singleton instance of TaskDefinitions.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return TaskDefinitions(directory=directory, config=config, validator=validator)
|
|
||||||
|
|
||||||
def attach(self, _: web.Application) -> None:
|
|
||||||
"""
|
|
||||||
Attach the TaskDefinitions service to the application.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
_ (web.Application): The aiohttp web application instance.
|
|
||||||
|
|
||||||
"""
|
|
||||||
Services.get_instance().add("task_definitions", self)
|
|
||||||
|
|
||||||
async def on_shutdown(self, _: web.Application) -> None:
|
|
||||||
"""
|
|
||||||
Handle application shutdown event.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
_ (web.Application): The aiohttp web application instance.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return
|
|
||||||
|
|
||||||
def _get_validator(self) -> Draft7Validator:
|
|
||||||
"""
|
|
||||||
Get or create the JSON schema validator for task definitions.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Draft7Validator: The JSON schema validator instance.
|
|
||||||
|
|
||||||
"""
|
|
||||||
if self._validator:
|
|
||||||
return self._validator
|
|
||||||
|
|
||||||
try:
|
|
||||||
contents: str = self._schema.read_text(encoding="utf-8")
|
|
||||||
schema = json.loads(contents)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Failed to read task definition schema '{self._schema}': {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._validator = Draft7Validator(schema)
|
|
||||||
except SchemaError as e:
|
|
||||||
LOG.error(f"Invalid task definition schema '{self._schema}': {e}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
return self._validator
|
|
||||||
|
|
||||||
def validate(self, definition: dict[str, Any]) -> None:
|
|
||||||
"""
|
|
||||||
Validate a task definition against the JSON schema.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
definition (dict[str, Any]): The task definition to validate.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the task definition is invalid.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
self._get_validator().validate(definition)
|
|
||||||
except ValidationError as e:
|
|
||||||
path: str = " ".join(str(part) for part in e.path)
|
|
||||||
error_path: str = f" ({path})" if path else ""
|
|
||||||
message: str = f"Task definition validation failed{error_path}: {e.message}"
|
|
||||||
raise ValueError(message) from e
|
|
||||||
|
|
||||||
def load(self) -> "TaskDefinitions":
|
|
||||||
"""
|
|
||||||
Load all task definitions from the directory.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TaskDefinitions: The current instance for chaining.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self._items.clear()
|
|
||||||
|
|
||||||
if not self._directory.exists():
|
|
||||||
return self
|
|
||||||
|
|
||||||
for file_path in sorted(self._directory.glob("*.json")):
|
|
||||||
stem: str = file_path.stem
|
|
||||||
|
|
||||||
try:
|
|
||||||
identifier_uuid = uuid.UUID(stem)
|
|
||||||
except ValueError:
|
|
||||||
LOG.warning(f"Skipping task definition with invalid UUID filename '{file_path.name}'.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if 4 != identifier_uuid.version:
|
|
||||||
LOG.warning(f"Skipping task definition '{file_path.name}', Name is not UUIDv4.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
contents: str = file_path.read_text(encoding="utf-8")
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Failed to load task definition '{file_path}': {e!s}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
parsed = json.loads(contents)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Failed to parse task definition '{file_path}': {e!s}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not isinstance(parsed, dict):
|
|
||||||
LOG.error(f"Invalid task definition file '{file_path}': must be a JSON object.")
|
|
||||||
continue
|
|
||||||
|
|
||||||
data: dict[str, Any] = parsed
|
|
||||||
|
|
||||||
identifier: str = str(identifier_uuid)
|
|
||||||
|
|
||||||
name_value: str = str(data.get("name") or identifier)
|
|
||||||
priority: int = self._normalize_priority(data.get("priority", 0))
|
|
||||||
data["priority"] = priority
|
|
||||||
|
|
||||||
record = TaskDefinitionRecord(
|
|
||||||
identifier=identifier,
|
|
||||||
filename=file_path.name,
|
|
||||||
name=name_value,
|
|
||||||
priority=priority,
|
|
||||||
path=file_path,
|
|
||||||
data=data,
|
|
||||||
updated_at=file_path.stat().st_mtime,
|
|
||||||
)
|
|
||||||
self._items[record.identifier] = record
|
|
||||||
|
|
||||||
return self
|
|
||||||
|
|
||||||
def list(self) -> list[TaskDefinitionRecord]:
|
|
||||||
"""
|
|
||||||
List all task definitions, sorted by priority and name.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
list[TaskDefinitionRecord]: List of task definitions sorted by priority and name.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return sorted(
|
|
||||||
self._items.values(),
|
|
||||||
key=lambda record: (record.priority, record.name.lower()),
|
|
||||||
)
|
|
||||||
|
|
||||||
def get(self, identifier: str) -> TaskDefinitionRecord | None:
|
|
||||||
"""
|
|
||||||
Get a task definition by its identifier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier (str): The UUID identifier of the task definition.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TaskDefinitionRecord | None: The task definition record, or None if not found.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self._items.get(identifier)
|
|
||||||
|
|
||||||
def _path_for(self, identifier: str) -> Path:
|
|
||||||
"""
|
|
||||||
Get the file path for a given task definition identifier.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier (str): The UUID identifier of the task definition.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path: The file path for the task definition JSON file.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return self._directory / f"{identifier}.json"
|
|
||||||
|
|
||||||
def _write_file(self, path: Path, payload: dict[str, Any]) -> None:
|
|
||||||
"""
|
|
||||||
Write a task definition to a file.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
path (Path): The file path to write to.
|
|
||||||
payload (dict[str, Any]): The task definition data to write.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
Exception: If writing to the file fails.
|
|
||||||
|
|
||||||
"""
|
|
||||||
path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
||||||
try:
|
|
||||||
path.chmod(0o600)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _normalize_priority(self, value: Any) -> int:
|
|
||||||
"""
|
|
||||||
Normalize the priority value to an integer.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
value (Any): The priority value to normalize.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
int: The normalized priority value.
|
|
||||||
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
priority = int(value)
|
|
||||||
except Exception:
|
|
||||||
priority = 0
|
|
||||||
|
|
||||||
return priority
|
|
||||||
|
|
||||||
def _refresh_generic_handler(self) -> None:
|
|
||||||
"""
|
|
||||||
Refresh the generic task handler definitions.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from .task_handlers.generic import GenericTaskHandler
|
|
||||||
|
|
||||||
GenericTaskHandler.refresh_definitions(force=True)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.error(f"Failed to refresh generic task handler: {e}")
|
|
||||||
|
|
||||||
def create(self, definition: dict[str, Any]) -> TaskDefinitionRecord:
|
|
||||||
"""
|
|
||||||
Create a new task definition.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
definition (dict[str, Any]): The task definition data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TaskDefinitionRecord: The created task definition record.
|
|
||||||
|
|
||||||
"""
|
|
||||||
self.validate(definition)
|
|
||||||
|
|
||||||
identifier: str = str(uuid.uuid4())
|
|
||||||
path: Path = self._path_for(identifier)
|
|
||||||
|
|
||||||
while path.exists():
|
|
||||||
identifier = str(uuid.uuid4())
|
|
||||||
path = self._path_for(identifier)
|
|
||||||
|
|
||||||
priority: int = self._normalize_priority(definition.get("priority", 0))
|
|
||||||
definition["priority"] = priority
|
|
||||||
|
|
||||||
self._write_file(path, definition)
|
|
||||||
|
|
||||||
record = TaskDefinitionRecord(
|
|
||||||
identifier=identifier,
|
|
||||||
filename=path.name,
|
|
||||||
name=str(definition.get("name", identifier)),
|
|
||||||
priority=priority,
|
|
||||||
path=path,
|
|
||||||
data=definition,
|
|
||||||
updated_at=path.stat().st_mtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._items[identifier] = record
|
|
||||||
self._refresh_generic_handler()
|
|
||||||
return record
|
|
||||||
|
|
||||||
def update(self, identifier: str, definition: dict[str, Any]) -> TaskDefinitionRecord:
|
|
||||||
"""
|
|
||||||
Update an existing task definition.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier (str): The UUID identifier of the task definition to update.
|
|
||||||
definition (dict[str, Any]): The updated task definition data.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TaskDefinitionRecord: The updated task definition record.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the task definition does not exist or is invalid.
|
|
||||||
|
|
||||||
"""
|
|
||||||
record: TaskDefinitionRecord | None = self.get(identifier)
|
|
||||||
if not record:
|
|
||||||
message: str = f"Task definition '{identifier}' does not exist."
|
|
||||||
raise ValueError(message)
|
|
||||||
|
|
||||||
self.validate(definition)
|
|
||||||
priority: int = self._normalize_priority(definition.get("priority", record.priority))
|
|
||||||
definition["priority"] = priority
|
|
||||||
self._write_file(record.path, definition)
|
|
||||||
|
|
||||||
updated_record = TaskDefinitionRecord(
|
|
||||||
identifier=identifier,
|
|
||||||
filename=record.filename,
|
|
||||||
name=str(definition.get("name", identifier)),
|
|
||||||
priority=priority,
|
|
||||||
path=record.path,
|
|
||||||
data=definition,
|
|
||||||
updated_at=record.path.stat().st_mtime,
|
|
||||||
)
|
|
||||||
|
|
||||||
self._items[identifier] = updated_record
|
|
||||||
self._refresh_generic_handler()
|
|
||||||
return updated_record
|
|
||||||
|
|
||||||
def delete(self, identifier: str) -> None:
|
|
||||||
"""
|
|
||||||
Delete a task definition.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
identifier (str): The UUID identifier of the task definition to delete.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
ValueError: If the task definition does not exist.
|
|
||||||
|
|
||||||
"""
|
|
||||||
record: TaskDefinitionRecord | None = self.get(identifier)
|
|
||||||
if not record:
|
|
||||||
message: str = f"Task definition '{identifier}' does not exist."
|
|
||||||
raise ValueError(message)
|
|
||||||
|
|
||||||
try:
|
|
||||||
record.path.unlink(missing_ok=False)
|
|
||||||
except FileNotFoundError:
|
|
||||||
LOG.warning(f"Task definition file '{record.path}' already removed.")
|
|
||||||
except Exception as exc:
|
|
||||||
LOG.error(f"Failed to delete task definition '{identifier}': {exc}")
|
|
||||||
raise
|
|
||||||
|
|
||||||
self._items.pop(identifier, None)
|
|
||||||
self._refresh_generic_handler()
|
|
||||||
1072
app/library/Tasks.py
1072
app/library/Tasks.py
File diff suppressed because it is too large
Load diff
|
|
@ -18,6 +18,7 @@ from app.features.conditions.service import Conditions
|
||||||
from app.features.dl_fields.service import DLFields
|
from app.features.dl_fields.service import DLFields
|
||||||
from app.features.notifications.service import Notifications
|
from app.features.notifications.service import Notifications
|
||||||
from app.features.tasks.definitions.deps import get_task_definitions_repo
|
from app.features.tasks.definitions.deps import get_task_definitions_repo
|
||||||
|
from app.features.tasks.service import Tasks
|
||||||
from app.library.BackgroundWorker import BackgroundWorker
|
from app.library.BackgroundWorker import BackgroundWorker
|
||||||
from app.library.cache import Cache
|
from app.library.cache import Cache
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
|
|
@ -29,7 +30,6 @@ from app.library.Presets import Presets
|
||||||
from app.library.Scheduler import Scheduler
|
from app.library.Scheduler import Scheduler
|
||||||
from app.library.Services import Services
|
from app.library.Services import Services
|
||||||
from app.library.sqlite_store import SqliteStore
|
from app.library.sqlite_store import SqliteStore
|
||||||
from app.library.Tasks import Tasks
|
|
||||||
from app.library.UpdateChecker import UpdateChecker
|
from app.library.UpdateChecker import UpdateChecker
|
||||||
|
|
||||||
LOG = logging.getLogger("app")
|
LOG = logging.getLogger("app")
|
||||||
|
|
|
||||||
46
app/migrations/20260124171740_add_tasks_table.py
Normal file
46
app/migrations/20260124171740_add_tasks_table.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""
|
||||||
|
This module contains a db migration.
|
||||||
|
|
||||||
|
Migration Name: add_tasks_table
|
||||||
|
Migration Version: 20260124171740
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
|
||||||
|
async def upgrade(c):
|
||||||
|
sql: list[str] = [
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS tasks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
folder TEXT NOT NULL DEFAULT '',
|
||||||
|
preset TEXT NOT NULL DEFAULT '',
|
||||||
|
timer TEXT NOT NULL DEFAULT '',
|
||||||
|
template TEXT NOT NULL DEFAULT '',
|
||||||
|
cli TEXT NOT NULL DEFAULT '',
|
||||||
|
auto_start INTEGER NOT NULL DEFAULT 1,
|
||||||
|
handler_enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
'CREATE INDEX IF NOT EXISTS "ix_tasks_name" ON "tasks" ("name")',
|
||||||
|
'CREATE INDEX IF NOT EXISTS "ix_tasks_enabled" ON "tasks" ("enabled")',
|
||||||
|
'CREATE INDEX IF NOT EXISTS "ix_tasks_timer" ON "tasks" ("timer")',
|
||||||
|
]
|
||||||
|
for sql_stmt in sql:
|
||||||
|
await c.execute(text(sql_stmt))
|
||||||
|
|
||||||
|
|
||||||
|
async def downgrade(c):
|
||||||
|
sql: list[str] = [
|
||||||
|
'DROP INDEX IF EXISTS "ix_tasks_name"',
|
||||||
|
'DROP INDEX IF EXISTS "ix_tasks_enabled"',
|
||||||
|
'DROP INDEX IF EXISTS "ix_tasks_timer"',
|
||||||
|
'DROP TABLE IF EXISTS "tasks"',
|
||||||
|
]
|
||||||
|
for sql_stmt in sql:
|
||||||
|
await c.execute(text(sql_stmt))
|
||||||
|
|
@ -1,459 +1,3 @@
|
||||||
import logging
|
"""Migrated API routes for feature submodule."""
|
||||||
import uuid
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import TYPE_CHECKING, Any
|
|
||||||
|
|
||||||
from aiohttp import web
|
import app.features.tasks.router # noqa: F401
|
||||||
from aiohttp.web import Request, Response
|
|
||||||
|
|
||||||
from app.library.ag_utils import ag
|
|
||||||
from app.library.config import Config
|
|
||||||
from app.library.encoder import Encoder
|
|
||||||
from app.library.router import route
|
|
||||||
from app.library.Tasks import Task, TaskFailure, TaskResult, Tasks
|
|
||||||
from app.library.Utils import get_channel_images, get_file, init_class, parse_outtmpl, validate_url, validate_uuid
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@route("POST", "api/tasks/inspect", "task_handler_inspect")
|
|
||||||
async def task_handler_inspect(request: Request, tasks: Tasks, encoder: Encoder, config: Config) -> Response:
|
|
||||||
"""
|
|
||||||
Check if handler can process the given URL.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
tasks (Tasks): The tasks instance.
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
config (Config): The config instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object.
|
|
||||||
|
|
||||||
"""
|
|
||||||
data = await request.json()
|
|
||||||
|
|
||||||
url: str | None = data.get("url") if isinstance(data, dict) else None
|
|
||||||
if not url:
|
|
||||||
return web.json_response({"error": "url is required."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
static_only: bool = data.get("static_only", False) if isinstance(data, dict) else False
|
|
||||||
if not static_only:
|
|
||||||
try:
|
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
|
||||||
except ValueError as e:
|
|
||||||
return web.json_response({"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
preset: str = data.get("preset", "") if isinstance(data, dict) else ""
|
|
||||||
handler_name: str | None = data.get("handler") if isinstance(data, dict) else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
result: TaskResult | TaskFailure = await tasks.get_handler().inspect(
|
|
||||||
url=url, preset=preset, handler_name=handler_name, static_only=static_only
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.exception(e)
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Failed to inspect handler.", "message": str(e)},
|
|
||||||
status=web.HTTPInternalServerError.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
return web.json_response(
|
|
||||||
data=result,
|
|
||||||
status=web.HTTPBadRequest.status_code if isinstance(result, TaskFailure) else web.HTTPOk.status_code,
|
|
||||||
dumps=encoder.encode,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@route("GET", "api/tasks/", "tasks")
|
|
||||||
async def tasks(encoder: Encoder) -> Response:
|
|
||||||
"""
|
|
||||||
Get the tasks.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object.
|
|
||||||
|
|
||||||
"""
|
|
||||||
return web.json_response(data=Tasks.get_instance().get_all(), status=web.HTTPOk.status_code, dumps=encoder.encode)
|
|
||||||
|
|
||||||
|
|
||||||
@route("PUT", "api/tasks/", "tasks_add")
|
|
||||||
async def tasks_add(request: Request, encoder: Encoder) -> Response:
|
|
||||||
"""
|
|
||||||
Add tasks to the queue.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object
|
|
||||||
|
|
||||||
"""
|
|
||||||
data = await request.json()
|
|
||||||
|
|
||||||
if not isinstance(data, list):
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Invalid request body expecting list with dicts."},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks: list = []
|
|
||||||
|
|
||||||
ins = Tasks.get_instance()
|
|
||||||
|
|
||||||
for item in data:
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Invalid request body expecting list with dicts."},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not item.get("url"):
|
|
||||||
return web.json_response({"error": "url is required.", "data": item}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
if not item.get("id", None) or not validate_uuid(item.get("id"), version=4):
|
|
||||||
item["id"] = str(uuid.uuid4())
|
|
||||||
|
|
||||||
if not item.get("template", None):
|
|
||||||
item["template"] = ""
|
|
||||||
|
|
||||||
if not item.get("cli", None):
|
|
||||||
item["cli"] = ""
|
|
||||||
|
|
||||||
try:
|
|
||||||
Tasks.validate(item)
|
|
||||||
except ValueError as e:
|
|
||||||
return web.json_response(
|
|
||||||
{"error": f"Failed to validate task '{item.get('name', '??')}'. '{e!s}'"},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks.append(init_class(Task, item))
|
|
||||||
|
|
||||||
try:
|
|
||||||
tasks = ins.save(tasks=tasks).load().get_all()
|
|
||||||
except Exception as e:
|
|
||||||
LOG.exception(e)
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Failed to save tasks.", "message": str(e)},
|
|
||||||
status=web.HTTPInternalServerError.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
return web.json_response(data=tasks, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
|
||||||
|
|
||||||
|
|
||||||
@route("POST", "api/tasks/{id}/mark", "tasks_mark")
|
|
||||||
async def task_mark(request: Request, encoder: Encoder) -> Response:
|
|
||||||
"""
|
|
||||||
Mark all items from task as downloaded.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object
|
|
||||||
|
|
||||||
"""
|
|
||||||
task_id: str = request.match_info.get("id", None)
|
|
||||||
|
|
||||||
if not task_id:
|
|
||||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
tasks: Tasks = Tasks.get_instance()
|
|
||||||
try:
|
|
||||||
task: Task | None = tasks.get(task_id)
|
|
||||||
if not task:
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
_status, _message = await task.mark()
|
|
||||||
if not _status:
|
|
||||||
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
|
||||||
except ValueError as e:
|
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
|
|
||||||
@route("DELETE", "api/tasks/{id}/mark", "tasks_unmark")
|
|
||||||
async def task_unmark(request: Request, encoder: Encoder) -> Response:
|
|
||||||
"""
|
|
||||||
Remove All tasks items from download archive.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object
|
|
||||||
|
|
||||||
"""
|
|
||||||
task_id: str = request.match_info.get("id", None)
|
|
||||||
|
|
||||||
if not task_id:
|
|
||||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
tasks: Tasks = Tasks.get_instance()
|
|
||||||
try:
|
|
||||||
task: Task | None = tasks.get(task_id)
|
|
||||||
if not task:
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
_status, _message = await task.unmark()
|
|
||||||
if not _status:
|
|
||||||
return web.json_response(data={"error": _message}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
return web.json_response(data={"message": _message}, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
|
||||||
except ValueError as e:
|
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
|
|
||||||
@route("POST", "api/tasks/{id}/metadata", "tasks_metadata")
|
|
||||||
async def task_metadata(request: Request, config: Config, encoder: Encoder) -> Response:
|
|
||||||
"""
|
|
||||||
Generate metadata for the task.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
config (Config): The config instance.
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object
|
|
||||||
|
|
||||||
"""
|
|
||||||
task_id: str = request.match_info.get("id", None)
|
|
||||||
|
|
||||||
if not task_id:
|
|
||||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
tasks: Tasks = Tasks.get_instance()
|
|
||||||
try:
|
|
||||||
task: Task | None = tasks.get(task_id)
|
|
||||||
if not task:
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
(save_path, _) = get_file(config.download_path, task.folder)
|
|
||||||
if not str(save_path or "").startswith(str(config.download_path)):
|
|
||||||
return web.json_response(data={"error": "Invalid task folder."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
if not save_path.exists():
|
|
||||||
save_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
metadata, status, message = await task.fetch_metadata()
|
|
||||||
if not status:
|
|
||||||
return web.json_response(data={"error": message}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
if not task.folder:
|
|
||||||
try:
|
|
||||||
ytdlp_opts: dict = task.get_ytdlp_opts().get_all()
|
|
||||||
outtmpl: str = parse_outtmpl(
|
|
||||||
output_template=ytdlp_opts.get("outtmpl", {}).get("default", "{title} [{id}]"),
|
|
||||||
info_dict=metadata,
|
|
||||||
params=ytdlp_opts,
|
|
||||||
)
|
|
||||||
if outtmpl:
|
|
||||||
_path: Path = save_path / outtmpl
|
|
||||||
if not _path.is_dir():
|
|
||||||
_path: Path = _path.parent
|
|
||||||
|
|
||||||
(save_path, _) = get_file(config.download_path, _path.relative_to(config.download_path))
|
|
||||||
if not str(save_path or "").startswith(str(config.download_path)):
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": "Invalid final path folder."}, status=web.HTTPBadRequest.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
if not save_path.exists():
|
|
||||||
save_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.warning(f"Failed to resolve final path from outtmpl. '{e!s}'")
|
|
||||||
|
|
||||||
info = {
|
|
||||||
"id": ag(metadata, ["id", "channel_id"]),
|
|
||||||
"id_type": metadata.get("extractor", "").split(":")[0].lower() if metadata.get("extractor") else None,
|
|
||||||
"title": ag(metadata, ["title", "fulltitle"]) or None,
|
|
||||||
"description": metadata.get("description", ""),
|
|
||||||
"uploader": metadata.get("uploader", ""),
|
|
||||||
"tags": metadata.get("tags", []),
|
|
||||||
"year": metadata.get("release_year"),
|
|
||||||
"thumbnails": get_channel_images(metadata.get("thumbnails", {})),
|
|
||||||
}
|
|
||||||
|
|
||||||
if not info.get("title"):
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": "Failed to get title from metadata."}, status=web.HTTPBadRequest.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
LOG.info(f"Generating metadata for task '{task.name}' in '{save_path!s}'")
|
|
||||||
|
|
||||||
from yt_dlp.utils import sanitize_filename
|
|
||||||
|
|
||||||
from app.yt_dlp_plugins.postprocessor.nfo_maker import NFOMakerPP
|
|
||||||
|
|
||||||
title: str = sanitize_filename(info.get("title"))
|
|
||||||
info_file: Path = save_path / f"{title} [{info.get('id')}].info.json"
|
|
||||||
info_file.write_text(encoder.encode(metadata), encoding="utf-8")
|
|
||||||
info["json_file"] = str(info_file.relative_to(config.download_path))
|
|
||||||
|
|
||||||
xml_file: Path = save_path / "tvshow.nfo"
|
|
||||||
info["nfo_file"] = str(xml_file.relative_to(config.download_path))
|
|
||||||
|
|
||||||
xml_content = "<tvshow>\n"
|
|
||||||
xml_content += f" <title>{NFOMakerPP._escape_text(info.get('title'))}</title>\n"
|
|
||||||
if info.get("description"):
|
|
||||||
xml_content += (
|
|
||||||
f" <plot>{NFOMakerPP._escape_text(NFOMakerPP._clean_description(info.get('description')))}</plot>\n"
|
|
||||||
)
|
|
||||||
if info.get("id"):
|
|
||||||
xml_content += f" <id>{NFOMakerPP._escape_text(info.get('id'))}</id>\n"
|
|
||||||
if info.get("id_type") and info.get("id"):
|
|
||||||
xml_content += f' <uniqueid type="{NFOMakerPP._escape_text(info.get("id_type"))}" default="true">{NFOMakerPP._escape_text(info.get("id"))}</uniqueid>\n'
|
|
||||||
if info.get("uploader"):
|
|
||||||
xml_content += f" <studio>{NFOMakerPP._escape_text(info.get('uploader'))}</studio>\n"
|
|
||||||
if info.get("tags", []):
|
|
||||||
for tag in info.get("tags", []):
|
|
||||||
xml_content += f" <tags>{NFOMakerPP._escape_text(tag)}</tags>\n"
|
|
||||||
if info.get("year"):
|
|
||||||
xml_content += f" <year>{info.get('year')}</year>\n"
|
|
||||||
xml_content += " <status>Continuing</status>\n"
|
|
||||||
xml_content += "</tvshow>\n"
|
|
||||||
xml_file.write_text(xml_content, encoding="utf-8")
|
|
||||||
|
|
||||||
try:
|
|
||||||
from yt_dlp.utils.networking import random_user_agent
|
|
||||||
|
|
||||||
from app.library.httpx_client import async_client
|
|
||||||
|
|
||||||
ytdlp_args: dict = task.get_ytdlp_opts().get_all()
|
|
||||||
opts: dict[str, Any] = {
|
|
||||||
"headers": {
|
|
||||||
"User-Agent": request.headers.get("User-Agent", ytdlp_args.get("user_agent", random_user_agent())),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if proxy := ytdlp_args.get("proxy"):
|
|
||||||
opts["proxy"] = proxy
|
|
||||||
|
|
||||||
try:
|
|
||||||
from httpx_curl_cffi import AsyncCurlTransport, CurlOpt
|
|
||||||
|
|
||||||
opts["transport"] = AsyncCurlTransport(
|
|
||||||
impersonate="chrome",
|
|
||||||
default_headers=True,
|
|
||||||
curl_options={CurlOpt.FRESH_CONNECT: True},
|
|
||||||
)
|
|
||||||
opts.pop("headers", None)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
async with async_client(**opts) as client:
|
|
||||||
for key in info.get("thumbnails", {}):
|
|
||||||
try:
|
|
||||||
url = info["thumbnails"][key]
|
|
||||||
LOG.info(f"Fetching thumbnail '{key}' from '{url}'")
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
validate_url(url, allow_internal=config.allow_internal_urls)
|
|
||||||
except ValueError:
|
|
||||||
LOG.warning(f"Invalid thumbnail url '{url}'")
|
|
||||||
continue
|
|
||||||
|
|
||||||
resp = await client.request(method="GET", url=url, follow_redirects=True)
|
|
||||||
|
|
||||||
img_file = save_path / f"{key}.jpg"
|
|
||||||
img_file.write_bytes(resp.content)
|
|
||||||
info["thumbnails"][key] = str(img_file.relative_to(config.download_path))
|
|
||||||
except Exception as e:
|
|
||||||
LOG.warning(f"Failed to fetch thumbnail '{key}' from '{url}'. '{e!s}'")
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
LOG.warning(f"Failed to fetch thumbnails. '{e!s}'")
|
|
||||||
|
|
||||||
return web.json_response(data=info, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
|
||||||
except ValueError as e:
|
|
||||||
return web.json_response(data={"error": str(e)}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
|
|
||||||
@route("PATCH", "api/tasks/{id}", "tasks_update")
|
|
||||||
async def task_update(request: Request, encoder: Encoder) -> Response:
|
|
||||||
"""
|
|
||||||
Update specific fields of a task.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
request (Request): The request object.
|
|
||||||
encoder (Encoder): The encoder instance.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Response: The response object
|
|
||||||
|
|
||||||
"""
|
|
||||||
if not (task_id := request.match_info.get("id", None)):
|
|
||||||
return web.json_response(data={"error": "No task id."}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = await request.json()
|
|
||||||
except Exception as e:
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": "Invalid JSON in request body.", "message": str(e)},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
return web.json_response(
|
|
||||||
{"error": "Request body must be a JSON object."},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
tasks: Tasks = Tasks.get_instance()
|
|
||||||
try:
|
|
||||||
task: Task | None = tasks.get(task_id)
|
|
||||||
if not task:
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": f"Task '{task_id}' does not exist."}, status=web.HTTPNotFound.status_code
|
|
||||||
)
|
|
||||||
|
|
||||||
task_dict: dict = task.serialize()
|
|
||||||
protected_fields: set[str] = {"id"}
|
|
||||||
updated: bool = False
|
|
||||||
|
|
||||||
for key, value in data.items():
|
|
||||||
if key in protected_fields or key not in task_dict:
|
|
||||||
continue
|
|
||||||
|
|
||||||
setattr(task, key, value)
|
|
||||||
updated = True
|
|
||||||
|
|
||||||
if not updated:
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": "No valid fields to update."},
|
|
||||||
status=web.HTTPBadRequest.status_code,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
Tasks.validate(task)
|
|
||||||
except ValueError as e:
|
|
||||||
return web.json_response({"error": f"Validation failed: {e!s}"}, status=web.HTTPBadRequest.status_code)
|
|
||||||
|
|
||||||
tasks.save(tasks=tasks.get_all()).load()
|
|
||||||
|
|
||||||
return web.json_response(data=task, status=web.HTTPOk.status_code, dumps=encoder.encode)
|
|
||||||
except Exception as e:
|
|
||||||
LOG.exception(e)
|
|
||||||
return web.json_response(
|
|
||||||
data={"error": "Failed to update task.", "message": str(e)},
|
|
||||||
status=web.HTTPInternalServerError.status_code,
|
|
||||||
)
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -364,18 +364,18 @@ import { useStorage } from '@vueuse/core'
|
||||||
import { CronExpressionParser } from 'cron-parser'
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
import TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||||
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
import type { AutoCompleteOptions } from '~/types/autocomplete'
|
||||||
import type { exported_task, task_item } from '~/types/tasks'
|
import type { ExportedTask, Task } from '~/types/tasks'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
reference?: string | null | undefined
|
reference?: number | null | undefined
|
||||||
task: task_item
|
task: Task
|
||||||
addInProgress?: boolean
|
addInProgress?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emitter = defineEmits<{
|
const emitter = defineEmits<{
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
(e: 'submit', payload: { reference: string | null | undefined, task: task_item, archive_all?: boolean }): void
|
(e: 'submit', payload: { reference: number | null | undefined, task: Task, archive_all?: boolean }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
|
|
@ -391,7 +391,7 @@ const archiveAllAfterAdd = ref<boolean>(false)
|
||||||
|
|
||||||
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
const CHANNEL_REGEX = /^https?:\/\/(?:www\.)?youtube\.com\/(?:(?:channel\/(?<channelId>UC[0-9A-Za-z_-]{22}))|(?:c\/(?<customName>[A-Za-z0-9_-]+))|(?:user\/(?<userName>[A-Za-z0-9_-]+))|(?:@(?<handle>[A-Za-z0-9_-]+)))(?<suffix>\/.*)?\/?$/
|
||||||
const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
|
const GENERIC_RSS_REGEX = /\.(rss|atom)(\?.*)?$|handler=rss/i
|
||||||
const form = reactive<task_item>({ ...props.task })
|
const form = reactive<Task>({ ...props.task })
|
||||||
|
|
||||||
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
watch(() => config.ytdlp_options, newOptions => ytDlpOpt.value = newOptions
|
||||||
.filter(opt => !opt.ignored)
|
.filter(opt => !opt.ignored)
|
||||||
|
|
@ -468,7 +468,7 @@ const importItem = async (): Promise<void> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const item = decode(val) as exported_task
|
const item = decode(val) as ExportedTask
|
||||||
|
|
||||||
if ('task' !== item._type) {
|
if ('task' !== item._type) {
|
||||||
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
|
toast.error(`Invalid import string. Expected type 'task', got '${item._type}'.`)
|
||||||
|
|
|
||||||
512
ui/app/composables/useTasks.ts
Normal file
512
ui/app/composables/useTasks.ts
Normal file
|
|
@ -0,0 +1,512 @@
|
||||||
|
import { ref, readonly } from 'vue'
|
||||||
|
|
||||||
|
import { useNotification } from '~/composables/useNotification'
|
||||||
|
import { request, parse_list_response, parse_api_response, parse_api_error } from '~/utils'
|
||||||
|
import type {
|
||||||
|
Task,
|
||||||
|
TaskPatch,
|
||||||
|
TaskInspectRequest,
|
||||||
|
TaskInspectResponse,
|
||||||
|
TaskMetadataResponse,
|
||||||
|
} from '~/types/tasks'
|
||||||
|
import type { APIResponse, Pagination } from '~/types/responses'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of all tasks in memory.
|
||||||
|
*/
|
||||||
|
const tasks = ref<Array<Task>>([])
|
||||||
|
/**
|
||||||
|
* Pagination state for tasks list.
|
||||||
|
*/
|
||||||
|
const pagination = ref<Pagination>({
|
||||||
|
page: 1,
|
||||||
|
per_page: 50,
|
||||||
|
total: 0,
|
||||||
|
total_pages: 0,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
})
|
||||||
|
/**
|
||||||
|
* Indicates if a request is in progress.
|
||||||
|
*/
|
||||||
|
const isLoading = ref<boolean>(false)
|
||||||
|
/**
|
||||||
|
* Indicates if an add/update operation is in progress.
|
||||||
|
*/
|
||||||
|
const addInProgress = ref<boolean>(false)
|
||||||
|
/**
|
||||||
|
* Stores the last error message, if any.
|
||||||
|
*/
|
||||||
|
const lastError = ref<string | null>(null)
|
||||||
|
/**
|
||||||
|
* If true, methods will throw errors instead of returning null/false (for testing)
|
||||||
|
*/
|
||||||
|
const throwInstead = ref(false)
|
||||||
|
/**
|
||||||
|
* Notification composable for showing success/error messages.
|
||||||
|
*/
|
||||||
|
const notify = useNotification()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts tasks by name (A-Z).
|
||||||
|
* @param items Array of tasks
|
||||||
|
* @returns Sorted array of tasks
|
||||||
|
*/
|
||||||
|
const sortTasks = (items: Array<Task>): Array<Task> => {
|
||||||
|
return [...items].sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely reads JSON from a Response, returns null on error.
|
||||||
|
* @param response Fetch Response object
|
||||||
|
* @returns Parsed JSON or null
|
||||||
|
*/
|
||||||
|
const readJson = async (response: Response): Promise<unknown> => {
|
||||||
|
try {
|
||||||
|
const clone = response.clone()
|
||||||
|
return await clone.json()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws an error if the response is not OK, using API error message if available.
|
||||||
|
* @param response Fetch Response object
|
||||||
|
* @throws Error with message from API or status code
|
||||||
|
*/
|
||||||
|
const ensureSuccess = async (response: Response): Promise<void> => {
|
||||||
|
if (response.ok) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await readJson(response)
|
||||||
|
const message = await parse_api_error(payload)
|
||||||
|
throw new Error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles errors by updating lastError and showing a notification.
|
||||||
|
* @param error Error object or unknown
|
||||||
|
*/
|
||||||
|
const handleError = (error: unknown): void => {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||||
|
lastError.value = message
|
||||||
|
notify.error(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates or adds a task in the list, keeping sort order.
|
||||||
|
* Also increments pagination.total if it's a new task.
|
||||||
|
* @param item Task to update/add
|
||||||
|
*/
|
||||||
|
const updateTasksList = (item: Task): void => {
|
||||||
|
const isNew = !tasks.value.some(existing => existing.id === item.id)
|
||||||
|
tasks.value = sortTasks([
|
||||||
|
...tasks.value.filter(existing => existing.id !== item.id),
|
||||||
|
item,
|
||||||
|
])
|
||||||
|
if (isNew) {
|
||||||
|
pagination.value.total++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a task from the list by ID.
|
||||||
|
* Also decrements pagination.total.
|
||||||
|
* @param id Task ID
|
||||||
|
*/
|
||||||
|
const removeTask = (id: number) => {
|
||||||
|
const initialLength = tasks.value.length
|
||||||
|
tasks.value = tasks.value.filter(item => item.id !== id)
|
||||||
|
if (tasks.value.length < initialLength) {
|
||||||
|
pagination.value.total = Math.max(0, pagination.value.total - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads tasks from the API with pagination support.
|
||||||
|
* @param page Page number
|
||||||
|
* @param perPage Items per page
|
||||||
|
*/
|
||||||
|
const loadTasks = async (page: number = 1, perPage: number | undefined = undefined): Promise<void> => {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
let url = `/api/tasks/?page=${page}`
|
||||||
|
if (perPage !== undefined) {
|
||||||
|
url += `&per_page=${perPage}`
|
||||||
|
}
|
||||||
|
const response = await request(url)
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const { items, pagination: paginationData } = await parse_list_response<Task>(json)
|
||||||
|
|
||||||
|
tasks.value = sortTasks(items)
|
||||||
|
pagination.value = paginationData
|
||||||
|
lastError.value = null
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a single task by ID from the API.
|
||||||
|
* @param id Task ID
|
||||||
|
* @returns Task or null on error
|
||||||
|
*/
|
||||||
|
const getTask = async (id: number): Promise<Task | null> => {
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/tasks/${id}`)
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const task = await parse_api_response<Task>(json)
|
||||||
|
|
||||||
|
lastError.value = null
|
||||||
|
return task
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new task via API.
|
||||||
|
* @param task Task to create
|
||||||
|
* @param callback Optional callback with APIResponse result
|
||||||
|
* @returns Created task or null on error
|
||||||
|
*/
|
||||||
|
const createTask = async (
|
||||||
|
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
||||||
|
callback?: (response: APIResponse<Task>) => void,
|
||||||
|
): Promise<Task | null> => {
|
||||||
|
addInProgress.value = true
|
||||||
|
try {
|
||||||
|
const response = await request('/api/tasks/', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(task),
|
||||||
|
})
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const created = await parse_api_response<Task>(json)
|
||||||
|
|
||||||
|
updateTasksList(created)
|
||||||
|
notify.success('Task created.')
|
||||||
|
lastError.value = null
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: true, error: null, detail: null, data: created })
|
||||||
|
}
|
||||||
|
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||||
|
handleError(error)
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates an existing task via API (PUT - full update).
|
||||||
|
* @param id Task ID
|
||||||
|
* @param task Updated task data
|
||||||
|
* @param callback Optional callback with APIResponse result
|
||||||
|
* @returns Updated task or null on error
|
||||||
|
*/
|
||||||
|
const updateTask = async (
|
||||||
|
id: number,
|
||||||
|
task: Omit<Task, 'id' | 'created_at' | 'updated_at'>,
|
||||||
|
callback?: (response: APIResponse<Task>) => void,
|
||||||
|
): Promise<Task | null> => {
|
||||||
|
addInProgress.value = true
|
||||||
|
try {
|
||||||
|
// Explicitly remove id, created_at, updated_at fields if present
|
||||||
|
const { id: _, created_at: __, updated_at: ___, ...taskData } = task as Task
|
||||||
|
|
||||||
|
const response = await request(`/api/tasks/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(taskData),
|
||||||
|
})
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const updated = await parse_api_response<Task>(json)
|
||||||
|
|
||||||
|
updateTasksList(updated)
|
||||||
|
notify.success(`Task '${updated.name}' updated.`)
|
||||||
|
lastError.value = null
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: true, error: null, detail: null, data: updated })
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||||
|
handleError(error)
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partially updates an existing task via API (PATCH).
|
||||||
|
* @param id Task ID
|
||||||
|
* @param patch Partial task data to update
|
||||||
|
* @param callback Optional callback with APIResponse result
|
||||||
|
* @returns Updated task or null on error
|
||||||
|
*/
|
||||||
|
const patchTask = async (
|
||||||
|
id: number,
|
||||||
|
patch: TaskPatch,
|
||||||
|
callback?: (response: APIResponse<Task>) => void,
|
||||||
|
): Promise<Task | null> => {
|
||||||
|
addInProgress.value = true
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/tasks/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
})
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const updated = await parse_api_response<Task>(json)
|
||||||
|
|
||||||
|
updateTasksList(updated)
|
||||||
|
notify.success(`Task '${updated.name}' updated.`)
|
||||||
|
lastError.value = null
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: true, error: null, detail: null, data: updated })
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||||
|
handleError(error)
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: false, error: errorMessage, detail: error, data: undefined })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
addInProgress.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a task by ID via API.
|
||||||
|
* @param id Task ID
|
||||||
|
* @param callback Optional callback with APIResponse result
|
||||||
|
* @returns true if deleted, false on error
|
||||||
|
*/
|
||||||
|
const deleteTask = async (
|
||||||
|
id: number,
|
||||||
|
callback?: (response: APIResponse<boolean>) => void,
|
||||||
|
): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/tasks/${id}`, { method: 'DELETE' })
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
removeTask(id)
|
||||||
|
notify.success('Task deleted.')
|
||||||
|
lastError.value = null
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: true, error: null, detail: null, data: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unexpected error occurred.'
|
||||||
|
handleError(error)
|
||||||
|
|
||||||
|
if (callback) {
|
||||||
|
callback({ success: false, error: errorMessage, detail: error, data: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inspects a task handler to check if it can process the given URL.
|
||||||
|
* @param request Task inspect request parameters
|
||||||
|
* @returns Inspect result or null on error
|
||||||
|
*/
|
||||||
|
const inspectTaskHandler = async (request: TaskInspectRequest): Promise<TaskInspectResponse | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/tasks/inspect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
lastError.value = null
|
||||||
|
return json as TaskInspectResponse
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks all items from a task as downloaded in the download archive.
|
||||||
|
* @param id Task ID
|
||||||
|
* @returns Success message or null on error
|
||||||
|
*/
|
||||||
|
const markTaskItems = async (id: number): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/tasks/${id}/mark`, { method: 'POST' })
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const message = json.message || 'All items marked as downloaded.'
|
||||||
|
|
||||||
|
notify.success(message)
|
||||||
|
lastError.value = null
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes all items from a task from the download archive.
|
||||||
|
* @param id Task ID
|
||||||
|
* @returns Success message or null on error
|
||||||
|
*/
|
||||||
|
const unmarkTaskItems = async (id: number): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/tasks/${id}/mark`, { method: 'DELETE' })
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const message = json.message || 'All items removed from archive.'
|
||||||
|
|
||||||
|
notify.success(message)
|
||||||
|
lastError.value = null
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates metadata for a task (tvshow.nfo, info.json, thumbnails).
|
||||||
|
* @param id Task ID
|
||||||
|
* @returns Metadata response or null on error
|
||||||
|
*/
|
||||||
|
const generateTaskMetadata = async (id: number): Promise<TaskMetadataResponse | null> => {
|
||||||
|
try {
|
||||||
|
const response = await request(`/api/tasks/${id}/metadata`, { method: 'POST' })
|
||||||
|
await ensureSuccess(response)
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const metadata = await parse_api_response<TaskMetadataResponse>(json)
|
||||||
|
|
||||||
|
notify.success('Metadata generation completed.')
|
||||||
|
lastError.value = null
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
if (throwInstead.value) throw error
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears the last error message.
|
||||||
|
*/
|
||||||
|
const clearError = () => lastError.value = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets all state to initial values (for testing only).
|
||||||
|
*/
|
||||||
|
const __resetForTesting = () => {
|
||||||
|
tasks.value = []
|
||||||
|
pagination.value = {
|
||||||
|
page: 1,
|
||||||
|
per_page: 50,
|
||||||
|
total: 0,
|
||||||
|
total_pages: 0,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
}
|
||||||
|
isLoading.value = false
|
||||||
|
addInProgress.value = false
|
||||||
|
lastError.value = null
|
||||||
|
throwInstead.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useTasks composable
|
||||||
|
*
|
||||||
|
* Returns reactive state and CRUD methods for tasks.
|
||||||
|
* @returns Object with state and API methods
|
||||||
|
*/
|
||||||
|
export const useTasks = () => ({
|
||||||
|
tasks: readonly(tasks),
|
||||||
|
pagination: readonly(pagination),
|
||||||
|
isLoading: readonly(isLoading),
|
||||||
|
addInProgress: readonly(addInProgress),
|
||||||
|
lastError: readonly(lastError),
|
||||||
|
loadTasks,
|
||||||
|
getTask,
|
||||||
|
createTask,
|
||||||
|
updateTask,
|
||||||
|
patchTask,
|
||||||
|
deleteTask,
|
||||||
|
inspectTaskHandler,
|
||||||
|
markTaskItems,
|
||||||
|
unmarkTaskItems,
|
||||||
|
generateTaskMetadata,
|
||||||
|
clearError,
|
||||||
|
throwInstead,
|
||||||
|
__resetForTesting,
|
||||||
|
})
|
||||||
|
|
@ -67,8 +67,8 @@
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="toggleForm">
|
<div class="columns is-multiline" v-if="toggleForm">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task as task_item"
|
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="(task as Task)" @cancel="resetForm(true);"
|
||||||
@cancel="resetForm(true);" @submit="updateItem" />
|
@submit="updateItem" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -401,12 +401,6 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="columns is-multiline">
|
|
||||||
<div class="column is-12">
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredTasks || filteredTasks.length < 1)">
|
<div class="columns is-multiline" v-if="!toggleForm && (isLoading || !filteredTasks || filteredTasks.length < 1)">
|
||||||
<div class="column is-12">
|
<div class="column is-12">
|
||||||
<Message class="is-info" title="Loading" icon="fas fa-spinner fa-spin" v-if="isLoading">
|
<Message class="is-info" title="Loading" icon="fas fa-spinner fa-spin" v-if="isLoading">
|
||||||
|
|
@ -470,12 +464,15 @@ import { useStorage } from '@vueuse/core'
|
||||||
import { CronExpressionParser } from 'cron-parser'
|
import { CronExpressionParser } from 'cron-parser'
|
||||||
import Modal from '~/components/Modal.vue'
|
import Modal from '~/components/Modal.vue'
|
||||||
import { useConfirm } from '~/composables/useConfirm'
|
import { useConfirm } from '~/composables/useConfirm'
|
||||||
|
import { useTasks } from '~/composables/useTasks'
|
||||||
import TaskInspect from '~/components/TaskInspect.vue'
|
import TaskInspect from '~/components/TaskInspect.vue'
|
||||||
import type { task_item, exported_task, error_response } from '~/types/tasks'
|
import type { Task, ExportedTask } from '~/types/tasks'
|
||||||
import type { WSEP } from '~/types/sockets'
|
import type { WSEP } from '~/types/sockets'
|
||||||
import { sleep } from '~/utils'
|
import { sleep } from '~/utils'
|
||||||
import { useSessionCache } from '~/utils/cache'
|
import { useSessionCache } from '~/utils/cache'
|
||||||
|
|
||||||
|
type TaskWithUI = Task & { in_progress?: boolean }
|
||||||
|
|
||||||
const box = useConfirm()
|
const box = useConfirm()
|
||||||
const toast = useNotification()
|
const toast = useNotification()
|
||||||
const config = useConfigStore()
|
const config = useConfigStore()
|
||||||
|
|
@ -486,18 +483,19 @@ const sessionCache = useSessionCache()
|
||||||
const display_style = useStorage<string>("tasks_display_style", "cards")
|
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||||
|
|
||||||
const tasks = ref<Array<task_item>>([])
|
// Use the tasks composable
|
||||||
const task = ref<task_item | Record<string, unknown>>({})
|
const tasksComposable = useTasks()
|
||||||
const taskRef = ref<string>('')
|
const { tasks, isLoading, addInProgress } = tasksComposable
|
||||||
|
|
||||||
|
const task = ref<TaskWithUI | Record<string, unknown>>({})
|
||||||
|
const taskRef = ref<number | null>(null)
|
||||||
const toggleForm = ref<boolean>(false)
|
const toggleForm = ref<boolean>(false)
|
||||||
const isLoading = ref<boolean>(true)
|
const selectedElms = ref<Array<number>>([])
|
||||||
const addInProgress = ref<boolean>(false)
|
|
||||||
const selectedElms = ref<Array<string>>([])
|
|
||||||
const masterSelectAll = ref(false)
|
const masterSelectAll = ref(false)
|
||||||
const massRun = ref<boolean>(false)
|
const massRun = ref<boolean>(false)
|
||||||
const massDelete = ref<boolean>(false)
|
const massDelete = ref<boolean>(false)
|
||||||
const table_container = ref(false)
|
const table_container = ref(false)
|
||||||
const inspectTask = ref<task_item | null>(null)
|
const inspectTask = ref<TaskWithUI | null>(null)
|
||||||
|
|
||||||
const reset_dialog = () => ({
|
const reset_dialog = () => ({
|
||||||
visible: false,
|
visible: false,
|
||||||
|
|
@ -510,8 +508,6 @@ const reset_dialog = () => ({
|
||||||
|
|
||||||
const dialog_confirm = ref(reset_dialog())
|
const dialog_confirm = ref(reset_dialog())
|
||||||
|
|
||||||
const remove_keys = ['in_progress']
|
|
||||||
|
|
||||||
const query = ref()
|
const query = ref()
|
||||||
const toggleFilter = ref(false)
|
const toggleFilter = ref(false)
|
||||||
|
|
||||||
|
|
@ -526,14 +522,17 @@ watch(query, () => {
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
watch(masterSelectAll, value => {
|
watch(masterSelectAll, value => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for (const key in filteredTasks.value) {
|
for (const key in filteredTasks.value) {
|
||||||
const element = filteredTasks.value[key] as task_item
|
const element = filteredTasks.value[key] as Task
|
||||||
selectedElms.value.push(element.id)
|
if (element.id) {
|
||||||
|
selectedElms.value.push(element.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -544,13 +543,14 @@ watch(() => socket.isConnected, async () => {
|
||||||
socket.on('item_status', statusHandler)
|
socket.on('item_status', statusHandler)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
const CACHE_KEY = 'tasks:handler_support'
|
const CACHE_KEY = 'tasks:handler_support'
|
||||||
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {})
|
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {})
|
||||||
watch(taskHandlerSupport, (newValue) => sessionCache.set(CACHE_KEY, newValue), { deep: true })
|
watch(taskHandlerSupport, (newValue) => sessionCache.set(CACHE_KEY, newValue), { deep: true })
|
||||||
|
|
||||||
const getCacheKey = (task: task_item): string => `${task.id}:${task.url}`
|
const getCacheKey = (task: Task): string => `${task.id}:${task.url}`
|
||||||
|
|
||||||
const cleanStaleCache = (currentTasks: Array<task_item>) => {
|
const cleanStaleCache = (currentTasks: ReadonlyArray<Task>) => {
|
||||||
const validKeys = new Set(currentTasks.map(task => getCacheKey(task)))
|
const validKeys = new Set(currentTasks.map(task => getCacheKey(task)))
|
||||||
const cacheKeys = Object.keys(taskHandlerSupport.value)
|
const cacheKeys = Object.keys(taskHandlerSupport.value)
|
||||||
|
|
||||||
|
|
@ -561,7 +561,7 @@ const cleanStaleCache = (currentTasks: Array<task_item>) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const recheckHandlerSupport = async (updatedTasks: Array<task_item>) => {
|
const recheckHandlerSupport = async (updatedTasks: ReadonlyArray<Task>) => {
|
||||||
for (const task of updatedTasks) {
|
for (const task of updatedTasks) {
|
||||||
if (!task.timer && false !== task.handler_enabled) {
|
if (!task.timer && false !== task.handler_enabled) {
|
||||||
await checkHandlerSupport(task)
|
await checkHandlerSupport(task)
|
||||||
|
|
@ -569,7 +569,7 @@ const recheckHandlerSupport = async (updatedTasks: Array<task_item>) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkHandlerSupport = async (task: task_item): Promise<boolean> => {
|
const checkHandlerSupport = async (task: Task): Promise<boolean> => {
|
||||||
const cacheKey = getCacheKey(task)
|
const cacheKey = getCacheKey(task)
|
||||||
|
|
||||||
if (undefined !== taskHandlerSupport.value[cacheKey]) {
|
if (undefined !== taskHandlerSupport.value[cacheKey]) {
|
||||||
|
|
@ -577,13 +577,11 @@ const checkHandlerSupport = async (task: task_item): Promise<boolean> => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request('/api/tasks/inspect', {
|
const result = await tasksComposable.inspectTaskHandler({
|
||||||
method: 'POST',
|
url: task.url,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
static_only: true
|
||||||
body: JSON.stringify({ url: task.url, static_only: true })
|
|
||||||
})
|
})
|
||||||
const data = await response.json()
|
const supported = result?.matched === true
|
||||||
const supported = true === data?.matched
|
|
||||||
taskHandlerSupport.value[cacheKey] = supported
|
taskHandlerSupport.value[cacheKey] = supported
|
||||||
return supported
|
return supported
|
||||||
} catch {
|
} catch {
|
||||||
|
|
@ -592,7 +590,7 @@ const checkHandlerSupport = async (task: task_item): Promise<boolean> => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const willTaskBeProcessed = (task: task_item): boolean => {
|
const willTaskBeProcessed = (task: Task): boolean => {
|
||||||
if (false === task.enabled) {
|
if (false === task.enabled) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
@ -604,41 +602,29 @@ const willTaskBeProcessed = (task: task_item): boolean => {
|
||||||
return hasTimer || hasHandler
|
return hasTimer || hasHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredTasks = computed<task_item[]>(() => {
|
const filteredTasks = computed(() => {
|
||||||
const q = query.value?.toLowerCase();
|
const q = query.value?.toLowerCase();
|
||||||
if (!q) return tasks.value;
|
if (!q) return tasks.value;
|
||||||
|
|
||||||
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
|
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
|
||||||
});
|
}) as ComputedRef<Array<TaskWithUI>>;
|
||||||
|
|
||||||
const reloadContent = async (fromMounted: boolean = false) => {
|
const reloadContent = async (fromMounted: boolean = false) => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
await tasksComposable.loadTasks()
|
||||||
const response = await request('/api/tasks')
|
|
||||||
|
|
||||||
if (fromMounted && !response.ok) {
|
if (tasks.value.length > 0) {
|
||||||
return
|
cleanStaleCache(tasks.value)
|
||||||
|
await recheckHandlerSupport(tasks.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
|
||||||
if (data.length < 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.value = data
|
|
||||||
cleanStaleCache(data)
|
|
||||||
await recheckHandlerSupport(data)
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (fromMounted) {
|
if (!fromMounted) {
|
||||||
return
|
console.error(e)
|
||||||
}
|
}
|
||||||
console.error(e)
|
|
||||||
toast.error('Failed to fetch tasks.')
|
|
||||||
} finally {
|
|
||||||
isLoading.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const resetForm = (closeForm: boolean = false) => {
|
const resetForm = (closeForm: boolean = false) => {
|
||||||
task.value = {
|
task.value = {
|
||||||
name: '',
|
name: '',
|
||||||
|
|
@ -652,44 +638,12 @@ const resetForm = (closeForm: boolean = false) => {
|
||||||
handler_enabled: true,
|
handler_enabled: true,
|
||||||
enabled: true
|
enabled: true
|
||||||
}
|
}
|
||||||
taskRef.value = ''
|
taskRef.value = null
|
||||||
addInProgress.value = false
|
|
||||||
if (closeForm) {
|
if (closeForm) {
|
||||||
toggleForm.value = false
|
toggleForm.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateTasks = async (items: Array<task_item>) => {
|
|
||||||
try {
|
|
||||||
addInProgress.value = true
|
|
||||||
|
|
||||||
const response = await request('/api/tasks', {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(items.map(item => cleanObject(toRaw(item), remove_keys))),
|
|
||||||
})
|
|
||||||
|
|
||||||
const data: Array<task_item> | error_response = await response.json()
|
|
||||||
|
|
||||||
if ("error" in data) {
|
|
||||||
toast.error(`Failed to update tasks. ${data.error}`);
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.value = data
|
|
||||||
cleanStaleCache(data)
|
|
||||||
await recheckHandlerSupport(data)
|
|
||||||
resetForm(true)
|
|
||||||
return true
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to update tasks. ${e.message}`);
|
|
||||||
} finally {
|
|
||||||
addInProgress.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteConfirm = () => {
|
const deleteConfirm = () => {
|
||||||
if (selectedElms.value.length < 1) {
|
if (selectedElms.value.length < 1) {
|
||||||
toast.error('No tasks selected.')
|
toast.error('No tasks selected.')
|
||||||
|
|
@ -710,139 +664,113 @@ const deleteSelected = async () => {
|
||||||
massDelete.value = true
|
massDelete.value = true
|
||||||
dialog_confirm.value = reset_dialog()
|
dialog_confirm.value = reset_dialog()
|
||||||
|
|
||||||
const itemsToDelete = tasks.value.filter(t => selectedElms.value.includes(t.id))
|
const itemsToDelete = tasks.value.filter(t => t.id && selectedElms.value.includes(t.id))
|
||||||
if (itemsToDelete.length < 1) {
|
if (itemsToDelete.length < 1) {
|
||||||
toast.error('No tasks found to delete.')
|
toast.error('No tasks found to delete.')
|
||||||
|
massDelete.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete tasks sequentially
|
||||||
for (const item of itemsToDelete) {
|
for (const item of itemsToDelete) {
|
||||||
const index = tasks.value.findIndex(t => t?.id === item.id)
|
if (item.id) {
|
||||||
if (index > -1) {
|
await tasksComposable.deleteTask(item.id)
|
||||||
tasks.value.splice(index, 1)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
const status = await updateTasks(tasks.value)
|
|
||||||
selectedElms.value = []
|
selectedElms.value = []
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Tasks deleted.')
|
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
massDelete.value = false
|
massDelete.value = false
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteItem = async (item: task_item) => {
|
const deleteItem = async (item: Task) => {
|
||||||
if (true !== (await box.confirm(`Delete '${item.name}' task?`))) {
|
if (true !== (await box.confirm(`Delete '${item.name}' task?`))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = tasks.value.findIndex((t) => t?.id === item.id)
|
if (!item.id) {
|
||||||
if (index > -1) {
|
toast.error('Task ID is missing')
|
||||||
tasks.value.splice(index, 1)
|
|
||||||
} else {
|
|
||||||
toast.error('Task not found')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const status = await updateTasks(tasks.value)
|
await tasksComposable.deleteTask(item.id)
|
||||||
|
|
||||||
if (!status) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Task deleted.')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleEnabled = async (item: task_item) => {
|
const toggleEnabled = async (item: Task) => {
|
||||||
|
if (!item.id) {
|
||||||
|
toast.error('Task ID is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const newStatus = !item.enabled
|
const newStatus = !item.enabled
|
||||||
const actionText = newStatus ? 'enable' : 'disable'
|
|
||||||
|
|
||||||
try {
|
const updated = await tasksComposable.patchTask(item.id, { enabled: newStatus })
|
||||||
const response = await request(`/api/tasks/${item.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ enabled: newStatus })
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data?.error) {
|
if (updated) {
|
||||||
toast.error(data.error)
|
// Update local reference
|
||||||
return
|
item.enabled = updated.enabled
|
||||||
|
|
||||||
|
// Update handler support cache if needed
|
||||||
|
if (updated.enabled) {
|
||||||
|
await checkHandlerSupport(updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
item.enabled = data.enabled
|
|
||||||
const func = data.enabled ? 'success' : 'warning'
|
|
||||||
toast[func](`Task '${item.name}' ${data.enabled ? 'enabled' : 'disabled'}.`)
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to ${actionText} task. ${e.message || 'Unknown error.'}`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleHandlerEnabled = async (item: task_item) => {
|
const toggleHandlerEnabled = async (item: Task) => {
|
||||||
const newStatus = !item.handler_enabled
|
if (!item.id) {
|
||||||
const actionText = newStatus ? 'enable' : 'disable'
|
toast.error('Task ID is missing')
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await request(`/api/tasks/${item.id}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ handler_enabled: newStatus })
|
|
||||||
})
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data?.error) {
|
|
||||||
toast.error(data.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
item.handler_enabled = data.handler_enabled
|
|
||||||
const func = data.handler_enabled ? 'success' : 'warning'
|
|
||||||
toast[func](`Task handler for '${item.name}' ${data.handler_enabled ? 'enabled' : 'disabled'}.`)
|
|
||||||
} catch (e: any) {
|
|
||||||
toast.error(`Failed to ${actionText} task handler. ${e.message || 'Unknown error.'}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateItem = async ({ reference, task, archive_all }: { reference?: string | null | undefined, task: task_item, archive_all?: boolean }) => {
|
|
||||||
if (reference) {
|
|
||||||
// -- find the task index.
|
|
||||||
const index = tasks.value.findIndex((t) => t?.id === reference)
|
|
||||||
if (index > -1) {
|
|
||||||
tasks.value[index] = task
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
tasks.value.push(task)
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = await updateTasks(tasks.value)
|
|
||||||
if (!status) {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success('Task updated.')
|
const newStatus = !item.handler_enabled
|
||||||
|
|
||||||
if (!reference && true === archive_all) {
|
const updated = await tasksComposable.patchTask(item.id, { handler_enabled: newStatus })
|
||||||
const newTask = tasks.value[tasks.value.length - 1]
|
|
||||||
if (newTask) {
|
if (updated) {
|
||||||
await sleep(1)
|
// Update local reference
|
||||||
await nextTick()
|
item.handler_enabled = updated.handler_enabled
|
||||||
await archiveAll(newTask, true)
|
|
||||||
|
// Update handler support cache if needed
|
||||||
|
if (updated.handler_enabled) {
|
||||||
|
await checkHandlerSupport(updated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateItem = async ({ reference, task, archive_all }: { reference?: number | null | undefined, task: Task, archive_all?: boolean }) => {
|
||||||
|
let createdOrUpdated: Task | null = null
|
||||||
|
|
||||||
|
if (reference) {
|
||||||
|
// Update existing task
|
||||||
|
createdOrUpdated = await tasksComposable.updateTask(reference, task)
|
||||||
|
} else {
|
||||||
|
// Create new task
|
||||||
|
createdOrUpdated = await tasksComposable.createTask(task)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!createdOrUpdated) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check handler support for the new/updated task
|
||||||
|
await checkHandlerSupport(createdOrUpdated)
|
||||||
|
|
||||||
|
if (!reference && true === archive_all && createdOrUpdated.id) {
|
||||||
|
await sleep(1)
|
||||||
|
await nextTick()
|
||||||
|
await archiveAll(createdOrUpdated, true)
|
||||||
|
}
|
||||||
|
|
||||||
resetForm(true)
|
resetForm(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const editItem = (item: task_item) => {
|
const editItem = (item: Task) => {
|
||||||
task.value = item
|
task.value = { ...item }
|
||||||
taskRef.value = item.id
|
taskRef.value = item.id ?? null
|
||||||
toggleForm.value = true
|
toggleForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -914,7 +842,7 @@ const runSelected = async () => {
|
||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const runNow = async (item: task_item, mass: boolean = false) => {
|
const runNow = async (item: TaskWithUI, mass: boolean = false) => {
|
||||||
if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) {
|
if (!mass && true !== (await box.confirm(`Run '${item.name}' now? it will also run at the scheduled time.`))) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -923,10 +851,10 @@ const runNow = async (item: task_item, mass: boolean = false) => {
|
||||||
item.in_progress = true
|
item.in_progress = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
const data: Record<string, unknown> = {
|
||||||
url: item.url,
|
url: item.url,
|
||||||
preset: item.preset,
|
preset: item.preset,
|
||||||
} as task_item
|
}
|
||||||
|
|
||||||
if (item.folder) {
|
if (item.folder) {
|
||||||
data.folder = item.folder
|
data.folder = item.folder
|
||||||
|
|
@ -967,7 +895,7 @@ const statusHandler = async (payload: WSEP['item_status']) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exportItem = async (item: task_item) => {
|
const exportItem = async (item: Task) => {
|
||||||
const info = JSON.parse(JSON.stringify(item))
|
const info = JSON.parse(JSON.stringify(item))
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
|
|
@ -979,7 +907,7 @@ const exportItem = async (item: task_item) => {
|
||||||
auto_start: info?.auto_start ?? true,
|
auto_start: info?.auto_start ?? true,
|
||||||
handler_enabled: info?.handler_enabled ?? true,
|
handler_enabled: info?.handler_enabled ?? true,
|
||||||
enabled: info?.enabled ?? true,
|
enabled: info?.enabled ?? true,
|
||||||
} as exported_task
|
} as ExportedTask
|
||||||
|
|
||||||
if (info.template) {
|
if (info.template) {
|
||||||
data.template = info.template
|
data.template = info.template
|
||||||
|
|
@ -1003,9 +931,13 @@ const get_tags = (name: string): Array<string> => {
|
||||||
|
|
||||||
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
const remove_tags = (name: string): string => name.replace(/\[(.*?)\]/g, '').trim();
|
||||||
|
|
||||||
const archiveAll = async (item: task_item, by_pass: boolean = false) => {
|
const archiveAll = async (item: TaskWithUI, by_pass: boolean = false) => {
|
||||||
try {
|
if (!item.id) {
|
||||||
|
toast.error('Task ID is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
if (true !== by_pass) {
|
if (true !== by_pass) {
|
||||||
const { status } = await cDialog({
|
const { status } = await cDialog({
|
||||||
message: `Mark all '${item.name}' items as downloaded in download archive?`
|
message: `Mark all '${item.name}' items as downloaded in download archive?`
|
||||||
|
|
@ -1017,16 +949,7 @@ const archiveAll = async (item: task_item, by_pass: boolean = false) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
item.in_progress = true
|
item.in_progress = true
|
||||||
|
await tasksComposable.markTaskItems(item.id)
|
||||||
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'POST' })
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data?.error) {
|
|
||||||
toast.error(data.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(data.message)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
|
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
|
||||||
return
|
return
|
||||||
|
|
@ -1035,7 +958,12 @@ const archiveAll = async (item: task_item, by_pass: boolean = false) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const unarchiveAll = async (item: task_item) => {
|
const unarchiveAll = async (item: TaskWithUI) => {
|
||||||
|
if (!item.id) {
|
||||||
|
toast.error('Task ID is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { status } = await cDialog({
|
const { status } = await cDialog({
|
||||||
message: `Remove all '${item.name}' items from download archive?`
|
message: `Remove all '${item.name}' items from download archive?`
|
||||||
|
|
@ -1046,16 +974,7 @@ const unarchiveAll = async (item: task_item) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
item.in_progress = true
|
item.in_progress = true
|
||||||
|
await tasksComposable.unmarkTaskItems(item.id)
|
||||||
const response = await request(`/api/tasks/${item.id}/mark`, { method: 'DELETE' })
|
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data?.error) {
|
|
||||||
toast.error(data.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success(data.message)
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
||||||
return
|
return
|
||||||
|
|
@ -1064,7 +983,12 @@ const unarchiveAll = async (item: task_item) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateMeta = async (item: task_item) => {
|
const generateMeta = async (item: TaskWithUI) => {
|
||||||
|
if (!item.id) {
|
||||||
|
toast.error('Task ID is missing')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { status } = await cDialog({
|
const { status } = await cDialog({
|
||||||
rawHTML: `
|
rawHTML: `
|
||||||
|
|
@ -1093,15 +1017,7 @@ const generateMeta = async (item: task_item) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
item.in_progress = true
|
item.in_progress = true
|
||||||
const response = await request(`/api/tasks/${item.id}/metadata`, { method: 'POST' })
|
await tasksComposable.generateTaskMetadata(item.id)
|
||||||
const data = await response.json()
|
|
||||||
|
|
||||||
if (data?.error) {
|
|
||||||
toast.error(data.error)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.success('Metadata generation completed.')
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,116 @@
|
||||||
type task_item = {
|
import type { Paginated } from '~/types/responses'
|
||||||
id: string,
|
|
||||||
name: string,
|
/**
|
||||||
url: string,
|
* Main Task interface matching backend Task schema.
|
||||||
preset?: string,
|
*/
|
||||||
folder?: string,
|
export interface Task {
|
||||||
template?: string,
|
id?: number
|
||||||
cli?: string,
|
name: string
|
||||||
timer?: string,
|
url: string
|
||||||
in_progress?: boolean,
|
folder?: string
|
||||||
auto_start?: boolean,
|
preset?: string
|
||||||
handler_enabled?: boolean,
|
timer?: string
|
||||||
enabled?: boolean,
|
template?: string
|
||||||
|
cli?: string
|
||||||
|
auto_start?: boolean
|
||||||
|
handler_enabled?: boolean
|
||||||
|
enabled?: boolean
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
type exported_task = task_item & { _type: string, _version: string }
|
/**
|
||||||
|
* Partial Task interface for PATCH operations.
|
||||||
|
*/
|
||||||
|
export type TaskPatch = Omit<Task, 'id' | 'created_at' | 'updated_at' | 'name' | 'url'> & {
|
||||||
|
name?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
type error_response = { error: string }
|
/**
|
||||||
|
* Paginated list of tasks response.
|
||||||
|
*/
|
||||||
|
export type TaskList = Paginated<Task>
|
||||||
|
|
||||||
export type { task_item, exported_task, error_response };
|
/**
|
||||||
|
* Task handler inspect request.
|
||||||
|
*/
|
||||||
|
export interface TaskInspectRequest {
|
||||||
|
url: string
|
||||||
|
preset?: string
|
||||||
|
handler?: string
|
||||||
|
static_only?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task handler inspect response - success.
|
||||||
|
*/
|
||||||
|
export interface TaskInspectSuccess {
|
||||||
|
matched: true
|
||||||
|
handler: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task handler inspect response - failure.
|
||||||
|
*/
|
||||||
|
export interface TaskInspectFailure {
|
||||||
|
matched: false
|
||||||
|
message: string
|
||||||
|
error: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task handler inspect response (union type).
|
||||||
|
*/
|
||||||
|
export type TaskInspectResponse = TaskInspectSuccess | TaskInspectFailure
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task metadata response.
|
||||||
|
*/
|
||||||
|
export interface TaskMetadataResponse {
|
||||||
|
id: string
|
||||||
|
id_type: string | null
|
||||||
|
title: string | null
|
||||||
|
description: string
|
||||||
|
uploader: string
|
||||||
|
tags: Array<string>
|
||||||
|
year: number | null
|
||||||
|
thumbnails: Record<string, string>
|
||||||
|
json_file?: string
|
||||||
|
nfo_file?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exported task for import/export functionality.
|
||||||
|
*/
|
||||||
|
export interface ExportedTask extends Omit<Task, 'id' | 'created_at' | 'updated_at' | 'in_progress'> {
|
||||||
|
_type: string
|
||||||
|
_version: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic error response.
|
||||||
|
*/
|
||||||
|
export interface ErrorResponse {
|
||||||
|
error: string
|
||||||
|
detail?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy alias for backward compatibility (deprecated).
|
||||||
|
* @deprecated Use Task instead
|
||||||
|
*/
|
||||||
|
export type task_item = Task
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy alias for backward compatibility (deprecated).
|
||||||
|
* @deprecated Use ExportedTask instead
|
||||||
|
*/
|
||||||
|
export type exported_task = ExportedTask
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy alias for backward compatibility (deprecated).
|
||||||
|
* @deprecated Use ErrorResponse instead
|
||||||
|
*/
|
||||||
|
export type error_response = ErrorResponse
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useStorage } from '@vueuse/core'
|
import { useStorage } from '@vueuse/core'
|
||||||
import type { convert_args_response,Paginated } from '~/types/responses'
|
import type { convert_args_response, Paginated } from '~/types/responses'
|
||||||
import type { StoreItem } from '~/types/store'
|
import type { StoreItem } from '~/types/store'
|
||||||
|
|
||||||
const AG_SEPARATOR = '.'
|
const AG_SEPARATOR = '.'
|
||||||
|
|
@ -858,9 +858,38 @@ const parse_api_error = async (json: unknown): Promise<string> => {
|
||||||
const payload = json as {
|
const payload = json as {
|
||||||
error?: string;
|
error?: string;
|
||||||
message?: string;
|
message?: string;
|
||||||
detail?: string[];
|
detail?: string | Array<{ loc: string[]; msg: string; type: string }>;
|
||||||
}
|
}
|
||||||
return String(payload.error ?? payload.message ?? payload.detail?.[0] ?? 'Unknown error occurred')
|
|
||||||
|
let extra_detail = ''
|
||||||
|
|
||||||
|
if (Array.isArray(payload.detail)) {
|
||||||
|
const errors = payload.detail.map((err: any) => {
|
||||||
|
if ('object' === typeof err && err.loc && err.msg) {
|
||||||
|
const field = Array.isArray(err.loc) ? err.loc[err.loc.length - 1] : 'unknown'
|
||||||
|
return `${field}: ${err.msg}`
|
||||||
|
}
|
||||||
|
return String(err)
|
||||||
|
})
|
||||||
|
extra_detail = errors.join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.error) {
|
||||||
|
return String(payload.error+(extra_detail ? ` - ${extra_detail}` : ''))
|
||||||
|
}
|
||||||
|
if (payload.message) {
|
||||||
|
return String(payload.message+(extra_detail ? ` - ${extra_detail}` : ''))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extra_detail) {
|
||||||
|
return extra_detail
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('string' === typeof payload.detail) {
|
||||||
|
return payload.detail
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown error occurred'
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
||||||
860
ui/tests/composables/useTasks.test.ts
Normal file
860
ui/tests/composables/useTasks.test.ts
Normal file
|
|
@ -0,0 +1,860 @@
|
||||||
|
import * as utils from '~/utils/index'
|
||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { useTasks } from '~/composables/useTasks'
|
||||||
|
import type { Task, TaskInspectResponse, TaskMetadataResponse, Pagination } from '~/types/tasks'
|
||||||
|
|
||||||
|
vi.mock('~/composables/useNotification', () => {
|
||||||
|
const success = vi.fn()
|
||||||
|
const error = vi.fn()
|
||||||
|
return {
|
||||||
|
useNotification: () => ({ success, error }),
|
||||||
|
default: () => ({ success, error }),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sample data
|
||||||
|
const mockTask: Task = {
|
||||||
|
id: 1,
|
||||||
|
name: 'Test Task',
|
||||||
|
url: 'https://www.youtube.com/channel/UCtest123',
|
||||||
|
folder: 'youtube/Test',
|
||||||
|
preset: 'default',
|
||||||
|
timer: '0 12 * * *',
|
||||||
|
template: '%(title)s.%(ext)s',
|
||||||
|
cli: '--format best',
|
||||||
|
auto_start: true,
|
||||||
|
handler_enabled: true,
|
||||||
|
enabled: true,
|
||||||
|
created_at: '2024-01-01T00:00:00Z',
|
||||||
|
updated_at: '2024-01-01T00:00:00Z',
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockPagination: Pagination = {
|
||||||
|
page: 1,
|
||||||
|
per_page: 50,
|
||||||
|
total: 1,
|
||||||
|
total_pages: 1,
|
||||||
|
has_next: false,
|
||||||
|
has_prev: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to create a mock Response object
|
||||||
|
function createMockResponse({ ok, status, jsonData }: { ok: boolean; status: number; jsonData: any }) {
|
||||||
|
return {
|
||||||
|
ok,
|
||||||
|
status,
|
||||||
|
headers: new Headers(),
|
||||||
|
redirected: false,
|
||||||
|
statusText: '',
|
||||||
|
type: 'basic',
|
||||||
|
url: '',
|
||||||
|
body: null,
|
||||||
|
bodyUsed: false,
|
||||||
|
clone() {
|
||||||
|
return this
|
||||||
|
},
|
||||||
|
async json() {
|
||||||
|
return jsonData
|
||||||
|
},
|
||||||
|
text: async () => JSON.stringify(jsonData),
|
||||||
|
arrayBuffer: async () => new ArrayBuffer(0),
|
||||||
|
blob: async () => new Blob(),
|
||||||
|
formData: async () => new FormData(),
|
||||||
|
} as Response
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useTasks', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
// Reset composable state between tests
|
||||||
|
const tasks = useTasks()
|
||||||
|
tasks.__resetForTesting()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loadTasks', () => {
|
||||||
|
it('loads tasks with pagination successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items: [mockTask],
|
||||||
|
pagination: mockPagination,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
expect(tasks.tasks.value).toHaveLength(1)
|
||||||
|
expect(tasks.tasks.value[0]).toEqual(mockTask)
|
||||||
|
expect(tasks.pagination.value).toEqual(mockPagination)
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts tasks by name A-Z', async () => {
|
||||||
|
const items = [
|
||||||
|
{ ...mockTask, id: 1, name: 'Zebra' },
|
||||||
|
{ ...mockTask, id: 2, name: 'Alpha' },
|
||||||
|
{ ...mockTask, id: 3, name: 'Beta' },
|
||||||
|
]
|
||||||
|
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items,
|
||||||
|
pagination: mockPagination,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
const sorted = tasks.tasks.value
|
||||||
|
expect(sorted[0].name).toBe('Alpha')
|
||||||
|
expect(sorted[1].name).toBe('Beta')
|
||||||
|
expect(sorted[2].name).toBe('Zebra')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles empty tasks list', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items: [],
|
||||||
|
pagination: { ...mockPagination, total: 0 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
expect(tasks.tasks.value).toEqual([])
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles errors during load', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
jsonData: { error: 'Server error' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('respects page and per_page parameters', async () => {
|
||||||
|
const requestSpy = vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items: [mockTask],
|
||||||
|
pagination: { ...mockPagination, page: 2 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks(2, 25)
|
||||||
|
|
||||||
|
expect(requestSpy).toHaveBeenCalledWith('/api/tasks/?page=2&per_page=25')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getTask', () => {
|
||||||
|
it('fetches a single task successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.getTask(1)
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTask)
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles 404 not found', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
jsonData: { error: 'Task not found' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.getTask(999)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createTask', () => {
|
||||||
|
it('creates a task successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const initialTotal = tasks.pagination.value.total
|
||||||
|
const newTask = { ...mockTask }
|
||||||
|
delete (newTask as any).id
|
||||||
|
|
||||||
|
const result = await tasks.createTask(newTask)
|
||||||
|
|
||||||
|
expect(result).toEqual(mockTask)
|
||||||
|
expect(tasks.tasks.value).toContainEqual(mockTask)
|
||||||
|
expect(tasks.pagination.value.total).toBe(initialTotal + 1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback on success', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
const newTask = { ...mockTask }
|
||||||
|
delete (newTask as any).id
|
||||||
|
|
||||||
|
await tasks.createTask(newTask, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
detail: null,
|
||||||
|
data: mockTask,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback on error', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
jsonData: { detail: [{ loc: ['name'], msg: 'Field required', type: 'value_error' }] },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
const newTask = { ...mockTask }
|
||||||
|
delete (newTask as any).id
|
||||||
|
|
||||||
|
await tasks.createTask(newTask, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.any(String),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updateTask', () => {
|
||||||
|
it('updates a task successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { ...mockTask, name: 'Updated Name' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const updated = { ...mockTask, name: 'Updated Name' }
|
||||||
|
|
||||||
|
const result = await tasks.updateTask(1, updated)
|
||||||
|
|
||||||
|
expect(result?.name).toBe('Updated Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback on success', async () => {
|
||||||
|
const updatedTask = { ...mockTask, name: 'Updated' }
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: updatedTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
|
||||||
|
await tasks.updateTask(1, updatedTask, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
detail: null,
|
||||||
|
data: updatedTask,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes id field from task before sending', async () => {
|
||||||
|
const requestSpy = vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const taskWithId = { ...mockTask }
|
||||||
|
await tasks.updateTask(1, taskWithId)
|
||||||
|
|
||||||
|
const requestBody = JSON.parse((requestSpy.mock.calls[0][1] as any).body)
|
||||||
|
// The id field should be removed before sending
|
||||||
|
expect('id' in requestBody).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('patchTask', () => {
|
||||||
|
it('patches a task successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { ...mockTask, enabled: false },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.patchTask(1, { enabled: false })
|
||||||
|
|
||||||
|
expect(result?.enabled).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback on patch success', async () => {
|
||||||
|
const patchedTask = { ...mockTask, enabled: false }
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: patchedTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
|
||||||
|
await tasks.patchTask(1, { enabled: false }, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
detail: null,
|
||||||
|
data: patchedTask,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles validation errors with callback', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
jsonData: { detail: [{ loc: ['timer'], msg: 'Invalid CRON expression', type: 'value_error' }] },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
|
||||||
|
await tasks.patchTask(1, { timer: 'invalid' }, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.any(String),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deleteTask', () => {
|
||||||
|
it('deletes a task successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const initialTotal = tasks.pagination.value.total
|
||||||
|
|
||||||
|
const result = await tasks.deleteTask(mockTask.id!)
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
expect(tasks.pagination.value.total).toBe(Math.max(0, initialTotal - 1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback on delete success', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
|
||||||
|
await tasks.deleteTask(1, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith({
|
||||||
|
success: true,
|
||||||
|
error: null,
|
||||||
|
detail: null,
|
||||||
|
data: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls callback on delete error', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
jsonData: { error: 'Task not found' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const callback = vi.fn()
|
||||||
|
const tasks = useTasks()
|
||||||
|
|
||||||
|
await tasks.deleteTask(999, callback)
|
||||||
|
|
||||||
|
expect(callback).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
success: false,
|
||||||
|
error: expect.any(String),
|
||||||
|
data: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('inspectTaskHandler', () => {
|
||||||
|
it('inspects task handler successfully', async () => {
|
||||||
|
const inspectResponse: TaskInspectResponse = {
|
||||||
|
matched: true,
|
||||||
|
handler: 'YoutubeHandler',
|
||||||
|
supported: true,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
url: 'https://www.youtube.com/watch?v=test123',
|
||||||
|
title: 'Test Video',
|
||||||
|
archive_id: 'youtube test123',
|
||||||
|
metadata: { published: '2024-01-01T00:00:00Z' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
feed_url: 'https://www.youtube.com/feeds/videos.xml?channel_id=UCtest123',
|
||||||
|
entry_count: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: inspectResponse,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.inspectTaskHandler({
|
||||||
|
url: 'https://www.youtube.com/channel/UCtest123',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual(inspectResponse)
|
||||||
|
expect(result?.matched).toBe(true)
|
||||||
|
expect(result?.handler).toBe('YoutubeHandler')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles handler not supported', async () => {
|
||||||
|
const inspectResponse: TaskInspectResponse = {
|
||||||
|
matched: false,
|
||||||
|
handler: null,
|
||||||
|
supported: false,
|
||||||
|
items: [],
|
||||||
|
metadata: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: inspectResponse,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.inspectTaskHandler({
|
||||||
|
url: 'https://unsupported.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result?.supported).toBe(false)
|
||||||
|
expect(result?.matched).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles inspect errors', async () => {
|
||||||
|
global.fetch = vi.fn().mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.inspectTaskHandler({
|
||||||
|
url: 'invalid',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('markTaskItems', () => {
|
||||||
|
it('marks all items as downloaded successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { message: 'Marked 15 items' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.markTaskItems(1)
|
||||||
|
|
||||||
|
expect(result).toBe('Marked 15 items')
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles mark errors', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
jsonData: { error: 'Task not found' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.markTaskItems(999)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('unmarkTaskItems', () => {
|
||||||
|
it('unmarks items successfully', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: { message: 'Unmarked 10 items' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.unmarkTaskItems(1)
|
||||||
|
|
||||||
|
expect(result).toBe('Unmarked 10 items')
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles unmark errors', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
jsonData: { error: 'Task not found' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.unmarkTaskItems(999)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('generateTaskMetadata', () => {
|
||||||
|
it('generates metadata successfully', async () => {
|
||||||
|
const metadataResponse: TaskMetadataResponse = {
|
||||||
|
status: 'success',
|
||||||
|
generated: ['tvshow.nfo', 'info.json', 'poster.jpg'],
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: metadataResponse,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.generateTaskMetadata(1)
|
||||||
|
|
||||||
|
expect(result).toEqual(metadataResponse)
|
||||||
|
expect(result?.status).toBe('success')
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles metadata generation errors', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
jsonData: { error: 'Failed to generate metadata' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const result = await tasks.generateTaskMetadata(1)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('error handling', () => {
|
||||||
|
it('throws when throwInstead is true', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
jsonData: { error: 'Server error' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
tasks.throwInstead.value = true
|
||||||
|
|
||||||
|
await expect(tasks.loadTasks()).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears error on clearError call', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
jsonData: { error: 'Server error' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
tasks.throwInstead.value = false
|
||||||
|
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
// Error should be set after failed load
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
|
||||||
|
tasks.clearError()
|
||||||
|
|
||||||
|
expect(tasks.lastError.value).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses API validation errors correctly', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
jsonData: {
|
||||||
|
detail: [
|
||||||
|
{ loc: ['name'], msg: 'Field required', type: 'value_error.missing' },
|
||||||
|
{ loc: ['url'], msg: 'Invalid URL format', type: 'value_error.url' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
// Clear any previous error
|
||||||
|
tasks.clearError()
|
||||||
|
|
||||||
|
const newTask = { ...mockTask, name: '', url: '' }
|
||||||
|
delete (newTask as any).id
|
||||||
|
|
||||||
|
const result = await tasks.createTask(newTask)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
expect(tasks.lastError.value).toBeTruthy()
|
||||||
|
expect(tasks.lastError.value).toContain('name: Field required')
|
||||||
|
expect(tasks.lastError.value).toContain('url: Invalid URL format')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addInProgress state', () => {
|
||||||
|
it('sets addInProgress during create operation', async () => {
|
||||||
|
let inProgressDuringCall = false
|
||||||
|
|
||||||
|
vi.spyOn(utils, 'request').mockImplementation(async (_url, _options) => {
|
||||||
|
// Get fresh instance to check current state
|
||||||
|
const currentTasks = useTasks()
|
||||||
|
inProgressDuringCall = currentTasks.addInProgress.value
|
||||||
|
return createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const newTask = { ...mockTask }
|
||||||
|
delete (newTask as any).id
|
||||||
|
|
||||||
|
await tasks.createTask(newTask)
|
||||||
|
|
||||||
|
expect(inProgressDuringCall).toBe(true)
|
||||||
|
expect(tasks.addInProgress.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets addInProgress on error', async () => {
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: false,
|
||||||
|
status: 400,
|
||||||
|
jsonData: { error: 'Bad request' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
const newTask = { ...mockTask }
|
||||||
|
delete (newTask as any).id
|
||||||
|
|
||||||
|
await tasks.createTask(newTask)
|
||||||
|
|
||||||
|
expect(tasks.addInProgress.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('isLoading state', () => {
|
||||||
|
it('sets isLoading during loadTasks operation', async () => {
|
||||||
|
let loadingDuringCall = false
|
||||||
|
|
||||||
|
vi.spyOn(utils, 'request').mockImplementation(async (_url, _options) => {
|
||||||
|
// Get fresh instance to check current state
|
||||||
|
const currentTasks = useTasks()
|
||||||
|
loadingDuringCall = currentTasks.isLoading.value
|
||||||
|
return createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items: [mockTask],
|
||||||
|
pagination: mockPagination,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
expect(loadingDuringCall).toBe(true)
|
||||||
|
expect(tasks.isLoading.value).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('task list updates', () => {
|
||||||
|
it('updates existing task in list', async () => {
|
||||||
|
// Load initial task
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items: [mockTask],
|
||||||
|
pagination: mockPagination,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
expect(tasks.tasks.value).toHaveLength(1)
|
||||||
|
expect(tasks.tasks.value[0].name).toBe('Test Task')
|
||||||
|
|
||||||
|
// Update task
|
||||||
|
const updatedTask = { ...mockTask, name: 'Updated Name' }
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: updatedTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await tasks.updateTask(1, updatedTask)
|
||||||
|
|
||||||
|
expect(tasks.tasks.value[0].name).toBe('Updated Name')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('removes deleted task from list', async () => {
|
||||||
|
// Load initial task
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: {
|
||||||
|
items: [mockTask],
|
||||||
|
pagination: { ...mockPagination, total: 1 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tasks = useTasks()
|
||||||
|
await tasks.loadTasks()
|
||||||
|
|
||||||
|
expect(tasks.tasks.value).toHaveLength(1)
|
||||||
|
expect(tasks.pagination.value.total).toBe(1)
|
||||||
|
|
||||||
|
// Delete task
|
||||||
|
vi.spyOn(utils, 'request').mockResolvedValueOnce(
|
||||||
|
createMockResponse({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
jsonData: mockTask,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await tasks.deleteTask(1)
|
||||||
|
|
||||||
|
expect(tasks.tasks.value).toHaveLength(0)
|
||||||
|
expect(tasks.pagination.value.total).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue