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"
|
||||
CONDITIONS = "conditions"
|
||||
NOTIFICATIONS = "notifications"
|
||||
TASKS = "tasks"
|
||||
TASKS_DEFINITIONS = "tasks_definitions"
|
||||
|
||||
def __str__(self) -> str:
|
||||
|
|
|
|||
|
|
@ -4,22 +4,22 @@ from typing import Any
|
|||
import httpx
|
||||
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.httpx_client import async_client
|
||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
||||
|
||||
|
||||
class BaseHandler:
|
||||
@staticmethod
|
||||
async def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
return False
|
||||
|
||||
@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
|
||||
|
||||
@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)
|
||||
|
||||
@staticmethod
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import jmespath
|
|||
from parsel import Selector
|
||||
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 (
|
||||
ExtractionRule,
|
||||
TaskDefinition,
|
||||
|
|
@ -23,7 +24,6 @@ from app.features.tasks.definitions.schemas import (
|
|||
from app.library.cache import Cache
|
||||
from app.library.config import Config
|
||||
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 ._base_handler import BaseHandler
|
||||
|
|
@ -112,7 +112,7 @@ class GenericTaskHandler(BaseHandler):
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
async def can_handle(task: Task) -> bool:
|
||||
async def can_handle(task: HandleTask) -> bool:
|
||||
"""
|
||||
Determine if this handler can process the given task.
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ class GenericTaskHandler(BaseHandler):
|
|||
return False
|
||||
|
||||
@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)
|
||||
if not definition:
|
||||
return TaskFailure(message="No generic task definition matched the provided URL.")
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ import re
|
|||
from typing import TYPE_CHECKING, Any
|
||||
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.Tasks import Task, TaskFailure, TaskItem, TaskResult
|
||||
from app.library.Utils import fetch_info, get_archive_id
|
||||
|
||||
from ._base_handler import BaseHandler
|
||||
|
|
@ -24,13 +24,13 @@ class RssGenericHandler(BaseHandler):
|
|||
)
|
||||
|
||||
@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}")
|
||||
return RssGenericHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _get(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
parsed: dict[str, str],
|
||||
) -> tuple[str, list[dict[str, str]], int]:
|
||||
|
|
@ -131,7 +131,7 @@ class RssGenericHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
"""
|
||||
Extract items from an RSS/Atom feed.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import logging
|
||||
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 ._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_]+)$")
|
||||
|
||||
@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}")
|
||||
return TverHandler.parse(task.url) is not None
|
||||
|
||||
|
|
@ -58,7 +58,7 @@ class TverHandler(BaseHandler):
|
|||
|
||||
@staticmethod
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
series_id: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
|
|
@ -142,7 +142,7 @@ class TverHandler(BaseHandler):
|
|||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
series_id: str | None = TverHandler.parse(task.url)
|
||||
if not series_id:
|
||||
return TaskFailure(message="Unrecognized Tver series URL.")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import re
|
|||
from typing import TYPE_CHECKING
|
||||
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 ._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})(?:\/.*)?$")
|
||||
|
||||
@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}")
|
||||
return TwitchHandler.parse(task.url) is not None
|
||||
|
||||
@staticmethod
|
||||
async def _collect_feed(
|
||||
task: Task,
|
||||
task: HandleTask,
|
||||
params: dict,
|
||||
handle_name: str,
|
||||
) -> tuple[str, list[dict[str, str]], bool]:
|
||||
|
|
@ -74,7 +74,7 @@ class TwitchHandler(BaseHandler):
|
|||
return feed_url, items, has_items
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
handle_name: str | None = TwitchHandler.parse(task.url)
|
||||
if not handle_name:
|
||||
return TaskFailure(message="Unrecognized Twitch channel URL.")
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import re
|
|||
from typing import TYPE_CHECKING, Any
|
||||
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 ._base_handler import BaseHandler
|
||||
|
|
@ -26,12 +26,12 @@ class YoutubeHandler(BaseHandler):
|
|||
)
|
||||
|
||||
@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}")
|
||||
return YoutubeHandler.parse(task.url) is not None
|
||||
|
||||
@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.
|
||||
|
||||
|
|
@ -89,7 +89,7 @@ class YoutubeHandler(BaseHandler):
|
|||
return feed_url, items, real_count
|
||||
|
||||
@staticmethod
|
||||
async def extract(task: Task) -> TaskResult | TaskFailure:
|
||||
async def extract(task: HandleTask) -> TaskResult | TaskFailure:
|
||||
parsed: dict[str, str] | None = YoutubeHandler.parse(task.url)
|
||||
if not parsed:
|
||||
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
|
||||
|
||||
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 (
|
||||
Definition,
|
||||
EngineConfig,
|
||||
RequestConfig,
|
||||
ResponseConfig,
|
||||
TaskDefinition,
|
||||
Definition,
|
||||
)
|
||||
from app.library.Tasks import Task, TaskFailure, TaskResult
|
||||
from app.features.tasks.definitions.results import HandleTask
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
|
|
@ -341,7 +342,7 @@ async def test_generic_task_handler_inspect(monkeypatch):
|
|||
return {"id": "test_video_1", "extractor_key": "Example"}
|
||||
|
||||
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)
|
||||
|
||||
assert isinstance(result, TaskResult), "Result should be TaskResult"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
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:
|
||||
|
|
@ -68,10 +69,10 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
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(
|
||||
id="test_rss",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test Atom Feed",
|
||||
url="https://example.com/feed.atom",
|
||||
preset="default",
|
||||
|
|
@ -112,10 +113,10 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(rss_feed)
|
||||
|
||||
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(
|
||||
id="test_rss",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test RSS Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -146,19 +147,19 @@ class TestRssHandlerExtraction:
|
|||
return DummyResponse(atom_feed)
|
||||
|
||||
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(
|
||||
id="test_rss",
|
||||
name="Test Feed",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Test rss Feed",
|
||||
url="https://example.com/feed.atom",
|
||||
preset="default",
|
||||
)
|
||||
|
||||
assert await RssGenericHandler.can_handle(task) is True
|
||||
|
||||
non_feed_task = Task(
|
||||
id="test_youtube",
|
||||
non_feed_task = HandleTask(
|
||||
id=1,
|
||||
name="YouTube Video",
|
||||
url="https://www.youtube.com/watch?v=abc123",
|
||||
preset="default",
|
||||
|
|
@ -185,10 +186,10 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(empty_feed)
|
||||
|
||||
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(
|
||||
id="test_empty",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Empty Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -203,17 +204,17 @@ class TestRssHandlerEdgeCases:
|
|||
@pytest.mark.asyncio
|
||||
async def test_invalid_feed_url(self, monkeypatch):
|
||||
"""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
|
||||
msg = "Network error"
|
||||
raise Exception(msg)
|
||||
|
||||
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(
|
||||
id="test_invalid",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Invalid Feed",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
@ -244,10 +245,10 @@ class TestRssHandlerEdgeCases:
|
|||
return DummyResponse(feed)
|
||||
|
||||
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(
|
||||
id="test_missing",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Feed with Missing URLs",
|
||||
url="https://example.com/feed.rss",
|
||||
preset="default",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
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:
|
||||
|
|
@ -118,9 +119,9 @@ async def test_tver_handler_extract(monkeypatch):
|
|||
raise RuntimeError(msg)
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -154,7 +155,7 @@ def test_tver_handler_parse(url: str, should_match: bool):
|
|||
@pytest.mark.asyncio
|
||||
async def test_tver_handler_can_handle():
|
||||
"""Test tver handler can_handle method."""
|
||||
task_valid = Task(id="test1", 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_valid = HandleTask(id=1, name="Test", url="https://tver.jp/series/sr8sb9pnhc", 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_invalid) is False
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
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:
|
||||
|
|
@ -39,10 +40,10 @@ async def test_twitch_handler_inspect(monkeypatch):
|
|||
return DummyResponse(feed)
|
||||
|
||||
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(
|
||||
id="inspect",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Inspect",
|
||||
url="https://www.twitch.tv/testchannel",
|
||||
preset="default",
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import pytest
|
||||
|
||||
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:
|
||||
|
|
@ -41,10 +42,10 @@ async def test_youtube_handler_inspect(monkeypatch):
|
|||
return DummyResponse(feed)
|
||||
|
||||
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(
|
||||
id="inspect",
|
||||
task = HandleTask(
|
||||
id=1,
|
||||
name="Inspect",
|
||||
url="https://www.youtube.com/channel/UCabcdefghijklmnopqrstuv",
|
||||
preset="default",
|
||||
|
|
@ -46,3 +46,24 @@ def schema_to_payload(item: TaskDefinition) -> dict[str, Any]:
|
|||
"enabled": item.enabled,
|
||||
"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:
|
||||
"""
|
||||
Remove a job from the schedule.
|
||||
Remove a job from the scheduler.
|
||||
|
||||
Args:
|
||||
id (str|list[str]): The id of the job to remove.
|
||||
|
|
@ -156,7 +156,7 @@ class Scheduler(metaclass=Singleton):
|
|||
return False
|
||||
|
||||
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 False
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ class Services(metaclass=Singleton):
|
|||
def get_instance() -> "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.
|
||||
|
||||
|
|
@ -57,12 +57,16 @@ class Services(metaclass=Singleton):
|
|||
service: The service instance.
|
||||
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:
|
||||
declared_type = type(service)
|
||||
|
||||
self.remove(name)
|
||||
self._services.append(ServiceEntry(name=name, declared_type=declared_type, instance=service))
|
||||
return self
|
||||
|
||||
def add_all(self, services: dict[str, Any]):
|
||||
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.notifications.service import Notifications
|
||||
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.cache import Cache
|
||||
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.Services import Services
|
||||
from app.library.sqlite_store import SqliteStore
|
||||
from app.library.Tasks import Tasks
|
||||
from app.library.UpdateChecker import UpdateChecker
|
||||
|
||||
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
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
"""Migrated API routes for feature submodule."""
|
||||
|
||||
from aiohttp import web
|
||||
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,
|
||||
)
|
||||
import app.features.tasks.router # noqa: F401
|
||||
|
|
|
|||
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 TextareaAutocomplete from '~/components/TextareaAutocomplete.vue'
|
||||
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'
|
||||
|
||||
const props = defineProps<{
|
||||
reference?: string | null | undefined
|
||||
task: task_item
|
||||
reference?: number | null | undefined
|
||||
task: Task
|
||||
addInProgress?: boolean
|
||||
}>()
|
||||
|
||||
const emitter = defineEmits<{
|
||||
(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()
|
||||
|
|
@ -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 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
|
||||
.filter(opt => !opt.ignored)
|
||||
|
|
@ -468,7 +468,7 @@ const importItem = async (): Promise<void> => {
|
|||
}
|
||||
|
||||
try {
|
||||
const item = decode(val) as exported_task
|
||||
const item = decode(val) as ExportedTask
|
||||
|
||||
if ('task' !== 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="column is-12">
|
||||
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="task as task_item"
|
||||
@cancel="resetForm(true);" @submit="updateItem" />
|
||||
<TaskForm :addInProgress="addInProgress" :reference="taskRef" :task="(task as Task)" @cancel="resetForm(true);"
|
||||
@submit="updateItem" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -401,12 +401,6 @@
|
|||
</template>
|
||||
</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="column is-12">
|
||||
<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 Modal from '~/components/Modal.vue'
|
||||
import { useConfirm } from '~/composables/useConfirm'
|
||||
import { useTasks } from '~/composables/useTasks'
|
||||
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 { sleep } from '~/utils'
|
||||
import { useSessionCache } from '~/utils/cache'
|
||||
|
||||
type TaskWithUI = Task & { in_progress?: boolean }
|
||||
|
||||
const box = useConfirm()
|
||||
const toast = useNotification()
|
||||
const config = useConfigStore()
|
||||
|
|
@ -486,18 +483,19 @@ const sessionCache = useSessionCache()
|
|||
const display_style = useStorage<string>("tasks_display_style", "cards")
|
||||
const isMobile = useMediaQuery({ maxWidth: 1024 })
|
||||
|
||||
const tasks = ref<Array<task_item>>([])
|
||||
const task = ref<task_item | Record<string, unknown>>({})
|
||||
const taskRef = ref<string>('')
|
||||
// Use the tasks composable
|
||||
const tasksComposable = useTasks()
|
||||
const { tasks, isLoading, addInProgress } = tasksComposable
|
||||
|
||||
const task = ref<TaskWithUI | Record<string, unknown>>({})
|
||||
const taskRef = ref<number | null>(null)
|
||||
const toggleForm = ref<boolean>(false)
|
||||
const isLoading = ref<boolean>(true)
|
||||
const addInProgress = ref<boolean>(false)
|
||||
const selectedElms = ref<Array<string>>([])
|
||||
const selectedElms = ref<Array<number>>([])
|
||||
const masterSelectAll = ref(false)
|
||||
const massRun = ref<boolean>(false)
|
||||
const massDelete = ref<boolean>(false)
|
||||
const table_container = ref(false)
|
||||
const inspectTask = ref<task_item | null>(null)
|
||||
const inspectTask = ref<TaskWithUI | null>(null)
|
||||
|
||||
const reset_dialog = () => ({
|
||||
visible: false,
|
||||
|
|
@ -510,8 +508,6 @@ const reset_dialog = () => ({
|
|||
|
||||
const dialog_confirm = ref(reset_dialog())
|
||||
|
||||
const remove_keys = ['in_progress']
|
||||
|
||||
const query = ref()
|
||||
const toggleFilter = ref(false)
|
||||
|
||||
|
|
@ -526,14 +522,17 @@ watch(query, () => {
|
|||
selectedElms.value = []
|
||||
})
|
||||
|
||||
|
||||
watch(masterSelectAll, value => {
|
||||
if (!value) {
|
||||
selectedElms.value = []
|
||||
return
|
||||
}
|
||||
for (const key in filteredTasks.value) {
|
||||
const element = filteredTasks.value[key] as task_item
|
||||
selectedElms.value.push(element.id)
|
||||
const element = filteredTasks.value[key] as Task
|
||||
if (element.id) {
|
||||
selectedElms.value.push(element.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -544,13 +543,14 @@ watch(() => socket.isConnected, async () => {
|
|||
socket.on('item_status', statusHandler)
|
||||
})
|
||||
|
||||
|
||||
const CACHE_KEY = 'tasks:handler_support'
|
||||
const taskHandlerSupport = ref<Record<string, boolean>>(sessionCache.get(CACHE_KEY) || {})
|
||||
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 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) {
|
||||
if (!task.timer && false !== task.handler_enabled) {
|
||||
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)
|
||||
|
||||
if (undefined !== taskHandlerSupport.value[cacheKey]) {
|
||||
|
|
@ -577,13 +577,11 @@ const checkHandlerSupport = async (task: task_item): Promise<boolean> => {
|
|||
}
|
||||
|
||||
try {
|
||||
const response = await request('/api/tasks/inspect', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: task.url, static_only: true })
|
||||
const result = await tasksComposable.inspectTaskHandler({
|
||||
url: task.url,
|
||||
static_only: true
|
||||
})
|
||||
const data = await response.json()
|
||||
const supported = true === data?.matched
|
||||
const supported = result?.matched === true
|
||||
taskHandlerSupport.value[cacheKey] = supported
|
||||
return supported
|
||||
} 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) {
|
||||
return false
|
||||
}
|
||||
|
|
@ -604,41 +602,29 @@ const willTaskBeProcessed = (task: task_item): boolean => {
|
|||
return hasTimer || hasHandler
|
||||
}
|
||||
|
||||
const filteredTasks = computed<task_item[]>(() => {
|
||||
const filteredTasks = computed(() => {
|
||||
const q = query.value?.toLowerCase();
|
||||
if (!q) return tasks.value;
|
||||
|
||||
return tasks.value.filter(task => deepIncludes(task, q, new WeakSet()));
|
||||
});
|
||||
}) as ComputedRef<Array<TaskWithUI>>;
|
||||
|
||||
const reloadContent = async (fromMounted: boolean = false) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const response = await request('/api/tasks')
|
||||
await tasksComposable.loadTasks()
|
||||
|
||||
if (fromMounted && !response.ok) {
|
||||
return
|
||||
if (tasks.value.length > 0) {
|
||||
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) {
|
||||
if (fromMounted) {
|
||||
return
|
||||
if (!fromMounted) {
|
||||
console.error(e)
|
||||
}
|
||||
console.error(e)
|
||||
toast.error('Failed to fetch tasks.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const resetForm = (closeForm: boolean = false) => {
|
||||
task.value = {
|
||||
name: '',
|
||||
|
|
@ -652,44 +638,12 @@ const resetForm = (closeForm: boolean = false) => {
|
|||
handler_enabled: true,
|
||||
enabled: true
|
||||
}
|
||||
taskRef.value = ''
|
||||
addInProgress.value = false
|
||||
taskRef.value = null
|
||||
if (closeForm) {
|
||||
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 = () => {
|
||||
if (selectedElms.value.length < 1) {
|
||||
toast.error('No tasks selected.')
|
||||
|
|
@ -710,139 +664,113 @@ const deleteSelected = async () => {
|
|||
massDelete.value = true
|
||||
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) {
|
||||
toast.error('No tasks found to delete.')
|
||||
massDelete.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Delete tasks sequentially
|
||||
for (const item of itemsToDelete) {
|
||||
const index = tasks.value.findIndex(t => t?.id === item.id)
|
||||
if (index > -1) {
|
||||
tasks.value.splice(index, 1)
|
||||
if (item.id) {
|
||||
await tasksComposable.deleteTask(item.id)
|
||||
}
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const status = await updateTasks(tasks.value)
|
||||
selectedElms.value = []
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Tasks deleted.')
|
||||
setTimeout(async () => {
|
||||
await nextTick()
|
||||
massDelete.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const deleteItem = async (item: task_item) => {
|
||||
const deleteItem = async (item: Task) => {
|
||||
if (true !== (await box.confirm(`Delete '${item.name}' task?`))) {
|
||||
return
|
||||
}
|
||||
|
||||
const index = tasks.value.findIndex((t) => t?.id === item.id)
|
||||
if (index > -1) {
|
||||
tasks.value.splice(index, 1)
|
||||
} else {
|
||||
toast.error('Task not found')
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing')
|
||||
return
|
||||
}
|
||||
|
||||
const status = await updateTasks(tasks.value)
|
||||
|
||||
if (!status) {
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Task deleted.')
|
||||
await tasksComposable.deleteTask(item.id)
|
||||
}
|
||||
|
||||
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 actionText = newStatus ? 'enable' : 'disable'
|
||||
|
||||
try {
|
||||
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()
|
||||
const updated = await tasksComposable.patchTask(item.id, { enabled: newStatus })
|
||||
|
||||
if (data?.error) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
if (updated) {
|
||||
// Update local reference
|
||||
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 newStatus = !item.handler_enabled
|
||||
const actionText = newStatus ? 'enable' : 'disable'
|
||||
|
||||
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) {
|
||||
const toggleHandlerEnabled = async (item: Task) => {
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing')
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Task updated.')
|
||||
const newStatus = !item.handler_enabled
|
||||
|
||||
if (!reference && true === archive_all) {
|
||||
const newTask = tasks.value[tasks.value.length - 1]
|
||||
if (newTask) {
|
||||
await sleep(1)
|
||||
await nextTick()
|
||||
await archiveAll(newTask, true)
|
||||
const updated = await tasksComposable.patchTask(item.id, { handler_enabled: newStatus })
|
||||
|
||||
if (updated) {
|
||||
// Update local reference
|
||||
item.handler_enabled = updated.handler_enabled
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
const editItem = (item: task_item) => {
|
||||
task.value = item
|
||||
taskRef.value = item.id
|
||||
const editItem = (item: Task) => {
|
||||
task.value = { ...item }
|
||||
taskRef.value = item.id ?? null
|
||||
toggleForm.value = true
|
||||
}
|
||||
|
||||
|
|
@ -914,7 +842,7 @@ const runSelected = async () => {
|
|||
}, 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.`))) {
|
||||
return
|
||||
}
|
||||
|
|
@ -923,10 +851,10 @@ const runNow = async (item: task_item, mass: boolean = false) => {
|
|||
item.in_progress = true
|
||||
}
|
||||
|
||||
const data = {
|
||||
const data: Record<string, unknown> = {
|
||||
url: item.url,
|
||||
preset: item.preset,
|
||||
} as task_item
|
||||
}
|
||||
|
||||
if (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 data = {
|
||||
|
|
@ -979,7 +907,7 @@ const exportItem = async (item: task_item) => {
|
|||
auto_start: info?.auto_start ?? true,
|
||||
handler_enabled: info?.handler_enabled ?? true,
|
||||
enabled: info?.enabled ?? true,
|
||||
} as exported_task
|
||||
} as ExportedTask
|
||||
|
||||
if (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 archiveAll = async (item: task_item, by_pass: boolean = false) => {
|
||||
try {
|
||||
const archiveAll = async (item: TaskWithUI, by_pass: boolean = false) => {
|
||||
if (!item.id) {
|
||||
toast.error('Task ID is missing')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (true !== by_pass) {
|
||||
const { status } = await cDialog({
|
||||
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
|
||||
|
||||
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)
|
||||
await tasksComposable.markTaskItems(item.id)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to archive items. ${e.message || 'Unknown error.'}`)
|
||||
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 {
|
||||
const { status } = await cDialog({
|
||||
message: `Remove all '${item.name}' items from download archive?`
|
||||
|
|
@ -1046,16 +974,7 @@ const unarchiveAll = async (item: task_item) => {
|
|||
}
|
||||
|
||||
item.in_progress = true
|
||||
|
||||
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)
|
||||
await tasksComposable.unmarkTaskItems(item.id)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to remove items from archive. ${e.message || 'Unknown error.'}`)
|
||||
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 {
|
||||
const { status } = await cDialog({
|
||||
rawHTML: `
|
||||
|
|
@ -1093,15 +1017,7 @@ const generateMeta = async (item: task_item) => {
|
|||
}
|
||||
|
||||
item.in_progress = true
|
||||
const response = await request(`/api/tasks/${item.id}/metadata`, { method: 'POST' })
|
||||
const data = await response.json()
|
||||
|
||||
if (data?.error) {
|
||||
toast.error(data.error)
|
||||
return
|
||||
}
|
||||
|
||||
toast.success('Metadata generation completed.')
|
||||
await tasksComposable.generateTaskMetadata(item.id)
|
||||
} catch (e: any) {
|
||||
toast.error(`Failed to generate metadata. ${e.message || 'Unknown error.'}`)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -1,20 +1,116 @@
|
|||
type task_item = {
|
||||
id: string,
|
||||
name: string,
|
||||
url: string,
|
||||
preset?: string,
|
||||
folder?: string,
|
||||
template?: string,
|
||||
cli?: string,
|
||||
timer?: string,
|
||||
in_progress?: boolean,
|
||||
auto_start?: boolean,
|
||||
handler_enabled?: boolean,
|
||||
enabled?: boolean,
|
||||
import type { Paginated } from '~/types/responses'
|
||||
|
||||
/**
|
||||
* Main Task interface matching backend Task schema.
|
||||
*/
|
||||
export interface Task {
|
||||
id?: number
|
||||
name: string
|
||||
url: string
|
||||
folder?: string
|
||||
preset?: string
|
||||
timer?: string
|
||||
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 type { convert_args_response,Paginated } from '~/types/responses'
|
||||
import type { convert_args_response, Paginated } from '~/types/responses'
|
||||
import type { StoreItem } from '~/types/store'
|
||||
|
||||
const AG_SEPARATOR = '.'
|
||||
|
|
@ -858,9 +858,38 @@ const parse_api_error = async (json: unknown): Promise<string> => {
|
|||
const payload = json as {
|
||||
error?: 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 {
|
||||
|
|
|
|||
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