diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce6856..d90d6b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Download Station module — Phase 1 (READ tools)** (#104). New module `mcp_synology.modules.downloadstation` adds five read-only tools — `list_downloads`, `get_download_info`, `get_download_stats`, `get_download_config`, `get_schedule` — that expose Synology's Download Station package via DSM API v1 (`SYNO.DownloadStation.Task`, `Info`, `Statistic`, `Schedule`). Module is opt-in via `instances.[name].modules.downloadstation.enabled: true`; the API preflight skips registration entirely when the DS package is not installed on the NAS, so users without DS pay zero LLM-context cost (no tools registered, no handler imports, no MODULE_INFO consumed beyond the preflight). Module shape mirrors File Station: domain-split files (`tasks.py`, `stats.py`, `config.py`, `helpers.py`), lazy handler imports inside `register()`, `respx`-mocked unit tests. Reuses the existing shared DSM session — DSM does not gate API access by session name, so no auth-manager changes were needed; ADR-0001's deferral of per-client sessions applies to client-isolation, not per-package isolation. `list_downloads` does client-side status filtering (DSM v1 `Task.list` lacks server-side filter support); `get_download_info` requests all five `additional` groups (`detail,transfer,file,tracker,peer`) and renders them as distinct sections; `get_schedule` parses DSM's 168-character `schedule_plan` into a 7×24 ASCII grid. Tool input validation uses the project's standard `error_response(ErrorCode.INVALID_PARAMETER, ...)` structured envelope. Phase 2 (task CRUD writes + global config writes) and Phase 3 (BT search + RSS) to follow as separate PRs. 60 new unit tests; every new module file (`__init__.py`, `tasks.py`, `stats.py`, `config.py`, `helpers.py`) at 100% line coverage. Full design at `docs/superpowers/specs/2026-05-13-downloadstation-module-design.md`. + - **ADR 0001 — Per-Client DSM Sessions and the Streamable HTTP Roadmap** (#PR_PLACEHOLDER) — closes #47. The 2026-04-16 project-wide review flagged the largest spec-vs-code gap: `docs/specs/architecture.md` documented a `session_key` parameter on `AuthManager.get_session()` for per-MCP-client DSM sessions under Streamable HTTP, but the live signature was `get_session() -> str` with no key and no plumbing. Three options were considered — ship the multi-session path now, retract the spec, or formally defer with the helper restructured. This PR records the deferral (Option 3) and closes the spec drift. New `docs/adr/0001-per-client-dsm-sessions.md` captures the question, options-with-tradeoffs, decision, consequences, and five concrete revisit triggers (Streamable HTTP gaining a concrete plan, multi-tenant deployment use case, subagent isolation request, tool-surface roughly doubling, single-shared-session pain showing up under real load). The ADR also inventories what the next implementation step looks like when a trigger fires, so the deferred work has a clear bounded shape rather than an open-ended re-design. Spec changes: `docs/specs/architecture.md` "Auth Manager" example reverted to the live `get_session()` signature with a forward-pointer to the planned section + ADR; the dedicated section renamed "Future" → "Planned: Per-Client Sessions (Streamable HTTP)" with an explicit deferral-not-oversight statement and a back-reference to the ADR. - **publish.yml: gate PyPI publish on `server.json` registry-schema validation** (#89) — closes #44. Adds a `validate-server-json` job that runs before `publish-pypi` on every release tag, mirroring the same check that runs on every PR via `ci.yml`. Without this gate, a malformed `server.json` (new required field in a future registry schema, type change, etc.) would PyPI-publish cleanly and then fail at the registry leg — leaving a discoverable PyPI release that isn't in the MCP registry, with no way to roll back PyPI short of yanking. v0.5.1 hit the registry-leg-fails-after-PyPI scenario for a *different* reason (mcp-publisher OIDC audience mismatch, fixed in #79), so the failure mode isn't theoretical. The new job uses the same `./.github/actions/install-mcp-publisher` composite that `publish-registry` uses, so the validating publisher version always matches the publishing one. Job runs in parallel with `build` (no dep on it), and `publish-pypi` now lists both as `needs:` so a validation failure stops the entire pipeline before any external side-effect. The optional `publish-manifest` artifact named in #44 is deferred — it's a tooling-version reconciliation aid that's only useful if a CI-PR / release-tag mismatch *does* fire, and is straightforward to add later when there's a concrete failure to debug. diff --git a/src/mcp_synology/modules/downloadstation/__init__.py b/src/mcp_synology/modules/downloadstation/__init__.py new file mode 100644 index 0000000..4858cd3 --- /dev/null +++ b/src/mcp_synology/modules/downloadstation/__init__.py @@ -0,0 +1,184 @@ +"""Download Station module: MODULE_INFO, register(), DownloadStationSettings. + +Phase 1 (READ tools): list_downloads, get_download_info, get_download_stats, +get_download_config, get_schedule. Phase 2 adds task CRUD writes; Phase 3 +adds BT search + RSS. See docs/superpowers/specs/2026-05-13-downloadstation-module-design.md. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from pydantic import BaseModel + +from mcp_synology.modules import ( + ApiRequirement, + ModuleInfo, + PermissionTier, + ToolInfo, + default_annotations, +) + +if TYPE_CHECKING: + from mcp_synology.modules import RegisterContext + + +class DownloadStationSettings(BaseModel): + """Download Station module settings. + + Phase 1 has no settings; the schema is declared so server.py module-loading + machinery can pass an empty settings dict cleanly. Fields land in the task + that needs them. + """ + + +MODULE_INFO = ModuleInfo( + name="downloadstation", + description=("Manage Synology Download Station tasks, schedule, and configuration"), + required_apis=[ + ApiRequirement(api_name="SYNO.DownloadStation.Task", min_version=1), + ApiRequirement(api_name="SYNO.DownloadStation.Info", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.DownloadStation.Schedule", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.DownloadStation.Statistic", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.DownloadStation.RSS.Site", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.DownloadStation.RSS.Feed", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.DownloadStation.BTSearch", min_version=1, optional=True), + ApiRequirement(api_name="SYNO.DownloadStation2.Task", min_version=1, optional=True), + ], + tools=[ + ToolInfo( + name="list_downloads", + description=( + "List download tasks in the Download Station queue. Filter by status " + "(downloading/finished/paused/error/all). Returns a table with id, title, " + "type (bt/http/ftp/nzb), status, size, progress%, current speed, and ETA." + ), + permission_tier=PermissionTier.READ, + ), + ToolInfo( + name="get_download_info", + description=( + "Get detailed information for a specific download task: detail (status, " + "destination, URI), transfer (size downloaded/uploaded, speed, peers), " + "files (per-file selection for BT), trackers (BT only), peers (BT only). " + "Use list_downloads first to find the task_id." + ), + permission_tier=PermissionTier.READ, + ), + ToolInfo( + name="get_download_stats", + description=( + "Get current Download Station throughput statistics: total download and " + "upload speed across all tasks, plus per-service (BT, HTTP/FTP, eMule) " + "breakdowns when those services are enabled." + ), + permission_tier=PermissionTier.READ, + ), + ToolInfo( + name="get_download_config", + description=( + "Get Download Station global configuration: BT max upload/download speeds, " + "default destination, scheduled-throttling plan summary, eMule enable state, " + "and other DSM-level DS settings." + ), + permission_tier=PermissionTier.READ, + ), + ToolInfo( + name="get_schedule", + description=( + "Get the Download Station weekly schedule as a 7-day × 24-hour grid. Each " + "cell shows whether downloads are off, on, or throttled at that hour. " + "Useful for verifying off-peak bandwidth policies." + ), + permission_tier=PermissionTier.READ, + ), + ], + settings_schema=DownloadStationSettings, +) + + +def register(ctx: RegisterContext) -> None: + """Register Download Station tools with the MCP server.""" + from mcp_synology.modules.downloadstation.config import ( + get_download_config, + get_schedule, + ) + from mcp_synology.modules.downloadstation.stats import get_download_stats + from mcp_synology.modules.downloadstation.tasks import ( + get_download_info, + list_downloads, + ) + + server = ctx.server + manager = ctx.manager + + _tool_annos = { + t.name: t.annotations or default_annotations(t.permission_tier) for t in MODULE_INFO.tools + } + + def _desc(name: str) -> str: + return next(t.description for t in MODULE_INFO.tools if t.name == name) + + if "list_downloads" in ctx.allowed_tools: + + @server.tool( + name="list_downloads", + description=_desc("list_downloads"), + annotations=_tool_annos["list_downloads"], + ) + async def tool_list_downloads( + status_filter: str = "all", + offset: int = 0, + limit: int = 100, + ) -> str: + client = await manager.get_client() + return await list_downloads( + client, + status_filter=status_filter, + offset=offset, + limit=limit, + ) + + if "get_download_info" in ctx.allowed_tools: + + @server.tool( + name="get_download_info", + description=_desc("get_download_info"), + annotations=_tool_annos["get_download_info"], + ) + async def tool_get_download_info(task_id: str) -> str: + client = await manager.get_client() + return await get_download_info(client, task_id=task_id) + + if "get_download_stats" in ctx.allowed_tools: + + @server.tool( + name="get_download_stats", + description=_desc("get_download_stats"), + annotations=_tool_annos["get_download_stats"], + ) + async def tool_get_download_stats() -> str: + client = await manager.get_client() + return await get_download_stats(client) + + if "get_download_config" in ctx.allowed_tools: + + @server.tool( + name="get_download_config", + description=_desc("get_download_config"), + annotations=_tool_annos["get_download_config"], + ) + async def tool_get_download_config() -> str: + client = await manager.get_client() + return await get_download_config(client) + + if "get_schedule" in ctx.allowed_tools: + + @server.tool( + name="get_schedule", + description=_desc("get_schedule"), + annotations=_tool_annos["get_schedule"], + ) + async def tool_get_schedule() -> str: + client = await manager.get_client() + return await get_schedule(client) diff --git a/src/mcp_synology/modules/downloadstation/config.py b/src/mcp_synology/modules/downloadstation/config.py new file mode 100644 index 0000000..7593cd2 --- /dev/null +++ b/src/mcp_synology/modules/downloadstation/config.py @@ -0,0 +1,100 @@ +"""Download Station config tools: get_download_config, get_schedule.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from mcp_synology.core.errors import ErrorCode, SynologyError +from mcp_synology.core.formatting import ( + error_response, + format_key_value, + synology_error_response, +) + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + + +def _kbps_str(kbps: int) -> str: + """Format a KB/s rate; 0 means unlimited per DSM convention.""" + if kbps <= 0: + return "unlimited" + return f"{kbps} KB/s" + + +def _bool_str(value: bool | None) -> str: + if value is None: + return "—" + return "yes" if value else "no" + + +async def get_download_config(client: DsmClient) -> str: + """Get Download Station global configuration.""" + try: + data = await client.request( + "SYNO.DownloadStation.Info", + "getconfig", + version=1, + ) + except SynologyError as e: + synology_error_response("Get download config", e) + + pairs: list[tuple[str, str]] = [ + ("Default destination", str(data.get("default_destination", "—"))), + ("BT max download", _kbps_str(int(data.get("bt_max_download", 0)))), + ("BT max upload", _kbps_str(int(data.get("bt_max_upload", 0)))), + ("HTTP max download", _kbps_str(int(data.get("http_max_download", 0)))), + ("FTP max download", _kbps_str(int(data.get("ftp_max_download", 0)))), + ("NZB max download", _kbps_str(int(data.get("nzb_max_download", 0)))), + ("eMule enabled", _bool_str(data.get("emule_enabled"))), + ("eMule max download", _kbps_str(int(data.get("emule_max_download", 0)))), + ("eMule max upload", _kbps_str(int(data.get("emule_max_upload", 0)))), + ("Auto-unzip enabled", _bool_str(data.get("unzip_service_enabled"))), + ] + + return format_key_value(pairs, title="Download Station configuration") + + +async def get_schedule(client: DsmClient) -> str: + """Get the weekly DS schedule as a 7×24 grid plus enable flags.""" + from mcp_synology.modules.downloadstation.helpers import format_schedule_grid + + try: + data = await client.request( + "SYNO.DownloadStation.Schedule", + "getconfig", + version=1, + ) + except SynologyError as e: + synology_error_response("Get download schedule", e) + + pairs: list[tuple[str, str]] = [ + ("Enabled", _bool_str(data.get("enabled"))), + ("eMule schedule enabled", _bool_str(data.get("emule_enabled"))), + ] + flags_block = format_key_value(pairs, title="Download Station schedule") + + plan = data.get("schedule_plan", "") + if not isinstance(plan, str): + error_response( + ErrorCode.INVALID_PARAMETER, + f"Get download schedule failed: schedule_plan is not a string ({type(plan).__name__}).", + retryable=False, + param="schedule_plan", + value=str(plan), + ) + try: + grid = format_schedule_grid(plan) + except ValueError as e: + error_response( + ErrorCode.INVALID_PARAMETER, + f"Get download schedule failed: {e}", + retryable=False, + param="schedule_plan", + value=plan, + ) + + return f"{flags_block}\n\n{grid}" diff --git a/src/mcp_synology/modules/downloadstation/helpers.py b/src/mcp_synology/modules/downloadstation/helpers.py new file mode 100644 index 0000000..c2ad941 --- /dev/null +++ b/src/mcp_synology/modules/downloadstation/helpers.py @@ -0,0 +1,129 @@ +"""Shared helpers for Download Station tools.""" + +from __future__ import annotations + +from mcp_synology.core.formatting import format_size + +# DSM Download Station task status numeric codes (public API guide). +_STATUS_LABELS: dict[int, str] = { + 1: "waiting", + 2: "downloading", + 3: "paused", + 4: "finishing", + 5: "finished", + 6: "hash_checking", + 7: "seeding", + 8: "filehosting_waiting", + 9: "extracting", + 10: "error", +} + +# Operator-facing groupings used by list_downloads(status_filter=...). +# "downloading" is interpreted as "in-flight or waiting for a slot, but not +# paused / finished / error" — so transient pre-active states (1 waiting, +# 8 filehosting_waiting) are bucketed there alongside actively-transferring +# states. +STATUS_GROUPS: dict[str, set[int]] = { + "downloading": {1, 2, 4, 6, 8, 9}, + "finished": {5, 7}, + "paused": {3}, + "error": {10}, +} + + +def format_task_status(status_code: int | None) -> str: + """Translate a DSM numeric status to its label. + + Unknown codes return ``unknown()`` so the original value is preserved + in diagnostic output; None returns plain ``unknown``. + """ + if status_code is None: + return "unknown" + return _STATUS_LABELS.get(status_code, f"unknown({status_code})") + + +def format_transfer_progress(downloaded: int, total: int) -> str: + """Render `` / (%)``. + + When ``total == 0`` percent is shown as an em dash. When DSM reports + ``downloaded > total`` (occasionally happens with seed-after-finish), + percent is clamped to 100. + """ + down_str = format_size(downloaded) + total_str = format_size(total) + if total <= 0: + return f"{down_str} / {total_str} (—)" + pct = min(100, int(downloaded * 100 / total)) + return f"{down_str} / {total_str} ({pct}%)" + + +def format_speed(bytes_per_sec: int) -> str: + """Render a B/s rate; non-positive values display as an em dash.""" + if bytes_per_sec <= 0: + return "—" + return f"{format_size(bytes_per_sec)}/s" + + +def format_eta(downloaded: int, total: int, speed: int) -> str: + """Render an estimated time-to-completion string. + + Returns an em dash when the ETA is undefined (zero/negative speed, or + downloaded already at-or-past total — including DSM's occasional + seed-after-finish overshoot). Otherwise renders one of: + + - ``Ns`` for under one minute + - ``Nm`` for under one hour + - ``NhMm`` for under one day + - ``NdMh`` for one day or more + """ + if speed <= 0 or total <= downloaded: + return "—" + remaining = total - downloaded + seconds = remaining // speed + if seconds < 60: + return f"{seconds}s" + if seconds < 3600: + return f"{seconds // 60}m" + if seconds < 86400: + return f"{seconds // 3600}h{(seconds % 3600) // 60}m" + return f"{seconds // 86400}d{(seconds % 86400) // 3600}h" + + +_DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"] + +# Per DSM convention: 7 days × 24 hours. Each char encodes one hour. +SCHEDULE_PLAN_LENGTH = 7 * 24 + +_CELL_GLYPHS: dict[str, str] = { + "0": ".", # off + "1": "#", # on (full) + "2": "~", # throttled + "3": "#", # on + eMule (older DSM) — render same as on +} + + +def format_schedule_grid(plan: str) -> str: + """Render a DSM Download Station weekly schedule plan as a text grid. + + ``plan`` is a 168-character string (7 days × 24 hours, Sun..Sat). Each + character encodes one hour: '0'=off, '1'=on, '2'=throttled, '3'=on+eMule. + + Output is one row per day with a 24-cell hour grid, plus a legend line. + """ + if len(plan) != SCHEDULE_PLAN_LENGTH: + msg = ( + f"schedule_plan must be {SCHEDULE_PLAN_LENGTH} chars " + f"(7 days × 24 hours), got {len(plan)}" + ) + raise ValueError(msg) + + hour_header = " " + " ".join(f"{h:02d}" for h in range(24)) + lines = [hour_header] + for day_idx, name in enumerate(_DAY_NAMES): + day_slice = plan[day_idx * 24 : (day_idx + 1) * 24] + cells = " ".join(_CELL_GLYPHS.get(ch, "?") for ch in day_slice) + lines.append(f"{name} {cells}") + + lines.append("") + lines.append("Legend: # = on ~ = throttled . = off") + return "\n".join(lines) diff --git a/src/mcp_synology/modules/downloadstation/stats.py b/src/mcp_synology/modules/downloadstation/stats.py new file mode 100644 index 0000000..e61b929 --- /dev/null +++ b/src/mcp_synology/modules/downloadstation/stats.py @@ -0,0 +1,49 @@ +"""Download Station stats tool: get_download_stats.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from mcp_synology.core.errors import SynologyError +from mcp_synology.core.formatting import ( + format_key_value, + synology_error_response, +) +from mcp_synology.modules.downloadstation.helpers import format_speed + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + + +async def get_download_stats(client: DsmClient) -> str: + """Get current throughput totals across the Download Station queue.""" + try: + data = await client.request( + "SYNO.DownloadStation.Statistic", + "getinfo", + version=1, + ) + except SynologyError as e: + synology_error_response("Get download stats", e) + + speed_down = int(data.get("speed_download", 0)) + speed_up = int(data.get("speed_upload", 0)) + + pairs: list[tuple[str, str]] = [ + ("Download (total)", format_speed(speed_down)), + ("Upload (total)", format_speed(speed_up)), + ] + + # eMule fields are only present when the eMule service is enabled. Use + # explicit key-presence checks rather than `or 0` so a real 0 reading + # (eMule enabled but idle) is preserved and rendered. + emule_down = data.get("emule_speed_download") + emule_up = data.get("emule_speed_upload") + if emule_down is not None or emule_up is not None: + pairs.append(("Download (eMule)", format_speed(int(emule_down or 0)))) + pairs.append(("Upload (eMule)", format_speed(int(emule_up or 0)))) + + return format_key_value(pairs, title="Download Station throughput") diff --git a/src/mcp_synology/modules/downloadstation/tasks.py b/src/mcp_synology/modules/downloadstation/tasks.py new file mode 100644 index 0000000..b7fa055 --- /dev/null +++ b/src/mcp_synology/modules/downloadstation/tasks.py @@ -0,0 +1,268 @@ +"""Download Station task tools: list_downloads, get_download_info.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Any + +from mcp_synology.core.errors import ErrorCode, SynologyError +from mcp_synology.core.formatting import ( + error_response, + format_key_value, + format_size, + format_table, + format_timestamp, + synology_error_response, +) +from mcp_synology.modules.downloadstation.helpers import ( + STATUS_GROUPS, + format_eta, + format_speed, + format_task_status, + format_transfer_progress, +) + +logger = logging.getLogger(__name__) + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + +_VALID_STATUS_FILTERS: set[str] = {"all", *STATUS_GROUPS.keys()} + + +def _format_epoch(epoch: int | None) -> str: + """Render an epoch timestamp using the shared formatter; '—' for None/0.""" + if not epoch: + return "—" + return format_timestamp(float(epoch)) + + +async def list_downloads( + client: DsmClient, + *, + status_filter: str = "all", + offset: int = 0, + limit: int = 100, +) -> str: + """List download tasks in the Download Station queue. + + The DSM v1 ``SYNO.DownloadStation.Task.list`` endpoint does not support + server-side status filtering, so we fetch and filter client-side. To keep + each row cheap, only ``detail`` and ``transfer`` additional groups are + requested — ``get_download_info`` fetches the full set for a single task. + """ + if status_filter not in _VALID_STATUS_FILTERS: + error_response( + ErrorCode.INVALID_PARAMETER, + f"List downloads failed: unknown status_filter {status_filter!r}.", + retryable=False, + param="status_filter", + value=status_filter, + valid=sorted(_VALID_STATUS_FILTERS), + ) + + try: + data = await client.request( + "SYNO.DownloadStation.Task", + "list", + version=1, + params={ + "offset": str(offset), + "limit": str(limit), + # DS Task API uses comma-separated additional groups, not the + # JSON-array format FileStation v2 uses. + "additional": "detail,transfer", + }, + ) + except SynologyError as e: + synology_error_response("List downloads", e) + + tasks: list[dict[str, Any]] = data.get("tasks", []) + + if status_filter != "all": + wanted = STATUS_GROUPS[status_filter] + tasks = [t for t in tasks if t.get("status") in wanted] + + if not tasks: + return format_table( + headers=["ID", "Title", "Type", "Status", "Size", "Progress", "Speed", "ETA"], + rows=[], + title=f"Download Station queue ({status_filter})", + ) + + rows: list[list[str]] = [] + for t in tasks: + task_id = t.get("id", "—") + title = t.get("title", "—") + ttype = t.get("type", "—") + status = format_task_status(t.get("status")) + size_total = int(t.get("size", 0)) + transfer = t.get("additional", {}).get("transfer", {}) + size_down = int(transfer.get("size_downloaded", 0)) + speed_down = int(transfer.get("speed_download", 0)) + progress = format_transfer_progress(size_down, size_total) + speed = format_speed(speed_down) + eta = format_eta(size_down, size_total, speed_down) + rows.append( + [ + task_id, + title, + ttype, + status, + format_size(size_total), + progress, + speed, + eta, + ] + ) + + total = data.get("total", len(rows)) + title = f"Download Station queue ({status_filter})" + result = format_table( + headers=["ID", "Title", "Type", "Status", "Size", "Progress", "Speed", "ETA"], + rows=rows, + title=title, + ) + result += f"\n\n{total} task(s) total; showing {len(rows)}." + return result + + +async def get_download_info( + client: DsmClient, + *, + task_id: str, +) -> str: + """Get detailed information for a single download task. + + Requests all ``additional`` groups in one round-trip and renders them as + distinct sections. + """ + try: + data = await client.request( + "SYNO.DownloadStation.Task", + "getinfo", + version=1, + params={ + "id": task_id, + # DS Task API uses comma-separated additional groups, not the + # JSON-array format FileStation v2 uses. + "additional": "detail,transfer,file,tracker,peer", + }, + ) + except SynologyError as e: + synology_error_response(f"Get download info ({task_id})", e) + + tasks: list[dict[str, Any]] = data.get("tasks", []) + if not tasks: + error_response( + ErrorCode.NOT_FOUND, + f"Task {task_id!r} not found.", + retryable=False, + param="task_id", + value=task_id, + ) + task = tasks[0] + + sections: list[str] = [] + + # Header block + header_pairs = [ + ("ID", task.get("id", "—")), + ("Title", task.get("title", "—")), + ("Type", task.get("type", "—")), + ("Status", format_task_status(task.get("status"))), + ("Size", format_size(int(task.get("size", 0)))), + ] + sections.append(format_key_value(header_pairs, title="Task")) + + add = task.get("additional", {}) or {} + + # Detail block + detail = add.get("detail", {}) or {} + if detail: + detail_pairs = [ + ("Destination", detail.get("destination", "—")), + ("URI", detail.get("uri", "—")), + ("Priority", detail.get("priority", "—")), + ("Created", _format_epoch(detail.get("create_time"))), + ("Started", _format_epoch(detail.get("started_time"))), + ("Completed", _format_epoch(detail.get("completed_time"))), + ] + sections.append(format_key_value(detail_pairs, title="Detail")) + + # Transfer block + transfer = add.get("transfer", {}) or {} + if transfer: + size_total = int(task.get("size", 0)) + size_down = int(transfer.get("size_downloaded", 0)) + size_up = int(transfer.get("size_uploaded", 0)) + transfer_pairs = [ + ("Downloaded", format_transfer_progress(size_down, size_total)), + ("Uploaded", format_size(size_up)), + ("Speed (down)", format_speed(int(transfer.get("speed_download", 0)))), + ("Speed (up)", format_speed(int(transfer.get("speed_upload", 0)))), + ] + sections.append(format_key_value(transfer_pairs, title="Transfer")) + + # File table (BT) + files = add.get("file", []) or [] + if files: + file_rows = [ + [ + f.get("filename", "—"), + format_size(int(f.get("size", 0))), + format_transfer_progress(int(f.get("size_downloaded", 0)), int(f.get("size", 0))), + f.get("priority", "—"), + ] + for f in files + ] + sections.append( + format_table( + headers=["File", "Size", "Progress", "Priority"], + rows=file_rows, + title="Files", + ) + ) + + # Tracker table (BT) + trackers = add.get("tracker", []) or [] + if trackers: + tracker_rows = [ + [ + tr.get("url", "—"), + tr.get("status", "—"), + str(tr.get("peers", 0)), + str(tr.get("seeds", 0)), + ] + for tr in trackers + ] + sections.append( + format_table( + headers=["Tracker", "Status", "Peers", "Seeds"], + rows=tracker_rows, + title="Trackers", + ) + ) + + # Peer table (BT) + peers = add.get("peer", []) or [] + if peers: + peer_rows = [ + [ + p.get("address", "—"), + p.get("agent", "—"), + f"{int(float(p.get('progress', 0)) * 100)}%", + format_speed(int(p.get("speed_download", 0))), + format_speed(int(p.get("speed_upload", 0))), + ] + for p in peers + ] + sections.append( + format_table( + headers=["Peer", "Client", "Progress", "Down", "Up"], + rows=peer_rows, + title="Peers", + ) + ) + + return "\n\n".join(sections) diff --git a/src/mcp_synology/server.py b/src/mcp_synology/server.py index 8586ef3..4adf5ce 100644 --- a/src/mcp_synology/server.py +++ b/src/mcp_synology/server.py @@ -17,6 +17,7 @@ from mcp_synology.core.client import DsmClient from mcp_synology.core.state import ServerState from mcp_synology.modules import PermissionTier, RegisterContext, filter_tools_by_permission +from mcp_synology.modules import downloadstation as _downloadstation_mod from mcp_synology.modules import filestation as _filestation_mod from mcp_synology.modules import system as _system_mod @@ -65,6 +66,7 @@ def _platform_label() -> str: # Known module registry — each entry exposes MODULE_INFO and register() _MODULE_REGISTRY: dict[str, ModuleType] = { + "downloadstation": _downloadstation_mod, "filestation": _filestation_mod, "system": _system_mod, } diff --git a/tests/conftest.py b/tests/conftest.py index 13679c0..60979f9 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,6 +82,14 @@ def make_api_cache() -> dict[str, ApiInfoEntry]: "SYNO.FileStation.Delete": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), "SYNO.FileStation.Upload": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), "SYNO.FileStation.Download": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=2), + "SYNO.DownloadStation.Task": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=3), + "SYNO.DownloadStation.Statistic": ApiInfoEntry( + path="entry.cgi", min_version=1, max_version=1 + ), + "SYNO.DownloadStation.Info": ApiInfoEntry(path="entry.cgi", min_version=1, max_version=1), + "SYNO.DownloadStation.Schedule": ApiInfoEntry( + path="entry.cgi", min_version=1, max_version=1 + ), } diff --git a/tests/integration_config.yaml.example b/tests/integration_config.yaml.example index 809deea..552dca4 100644 --- a/tests/integration_config.yaml.example +++ b/tests/integration_config.yaml.example @@ -22,6 +22,9 @@ connection: # password: your_password modules: + downloadstation: + enabled: false # default off; Phase 1 ships read-only tools — safe to enable + permission: read filestation: enabled: true permission: write # 'write' needed to test copy/move/delete diff --git a/tests/modules/downloadstation/__init__.py b/tests/modules/downloadstation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/modules/downloadstation/test_config.py b/tests/modules/downloadstation/test_config.py new file mode 100644 index 0000000..e7a3677 --- /dev/null +++ b/tests/modules/downloadstation/test_config.py @@ -0,0 +1,195 @@ +"""Tests for modules/downloadstation/config.py — get_download_config, get_schedule.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import respx + +from mcp_synology.modules.downloadstation.config import ( + get_download_config, +) +from tests.conftest import BASE_URL + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + + +class TestGetSchedule: + @respx.mock + async def test_renders_schedule_grid_and_flags(self, mock_client: DsmClient) -> None: + from mcp_synology.modules.downloadstation.config import get_schedule + + sunday = "0" + "1" + "2" + "0" * 21 + plan = sunday + "0" * (168 - 24) + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "enabled": True, + "emule_enabled": False, + "schedule_plan": plan, + }, + } + ) + result = await get_schedule(mock_client) + assert "Sun" in result + assert "Sat" in result + assert "Legend" in result + assert "Enabled" in result + assert "eMule" in result + + @respx.mock + async def test_disabled_schedule_shows_flag(self, mock_client: DsmClient) -> None: + from mcp_synology.modules.downloadstation.config import get_schedule + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "enabled": False, + "emule_enabled": False, + "schedule_plan": "0" * 168, + }, + } + ) + result = await get_schedule(mock_client) + # The "Enabled" pair should render "no" (format_key_value spacing + # may use either "Enabled: no" or "Enabled no" — accept either). + assert "Enabled" in result + assert "no" in result + + @respx.mock + async def test_dsm_error_propagates_as_tool_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + from mcp_synology.modules.downloadstation.config import get_schedule + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 105}}, + ) + try: + await get_schedule(mock_client) + except ToolError as e: + assert "105" in str(e) or "permission" in str(e).lower() + else: + raise AssertionError("expected ToolError") + + @respx.mock + async def test_malformed_plan_renders_error_but_does_not_crash( + self, mock_client: DsmClient + ) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + from mcp_synology.modules.downloadstation.config import get_schedule + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "enabled": True, + "emule_enabled": False, + "schedule_plan": "0" * 100, + }, + } + ) + try: + await get_schedule(mock_client) + except ToolError as e: + assert "168" in str(e) or "schedule_plan" in str(e) + else: + raise AssertionError("expected ToolError on malformed schedule_plan") + + +class TestGetDownloadConfig: + @respx.mock + async def test_renders_known_fields(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "bt_max_download": 0, + "bt_max_upload": 100, + "emule_enabled": False, + "emule_max_download": 0, + "emule_max_upload": 0, + "default_destination": "downloads", + "unzip_service_enabled": True, + }, + } + ) + result = await get_download_config(mock_client) + assert "BT max download" in result + assert "unlimited" in result.lower() # bt_max_download=0 renders unlimited + assert "downloads" in result # default_destination + assert "eMule" in result # row present whether enabled or not + + @respx.mock + async def test_dsm_error_propagates_as_tool_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 105}}, + ) + try: + await get_download_config(mock_client) + except ToolError as e: + assert "105" in str(e) or "permission" in str(e).lower() + else: + raise AssertionError("expected ToolError") + + +class TestGetScheduleNonStringPlan: + """Covers the schedule_plan-is-not-a-string branch (a real DSM oddity to + defend against — the API spec says string but a malformed firmware could + return None or a number). + """ + + @respx.mock + async def test_non_string_schedule_plan_raises_tool_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + from mcp_synology.modules.downloadstation.config import get_schedule + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "enabled": True, + "emule_enabled": False, + "schedule_plan": 12345, # non-string — invariant violation + }, + } + ) + try: + await get_schedule(mock_client) + except ToolError as e: + assert "schedule_plan" in str(e) or "not a string" in str(e) + else: + raise AssertionError("expected ToolError on non-string schedule_plan") + + +class TestGetDownloadConfigBoolNone: + """Covers _bool_str(None) — when DSM omits a bool field (older firmware + may omit unzip_service_enabled / emule_enabled), the field should render + as an em dash rather than 'no' or crashing. + """ + + @respx.mock + async def test_missing_emule_enabled_renders_em_dash(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "bt_max_download": 0, + "bt_max_upload": 0, + # emule_enabled deliberately absent + "default_destination": "downloads", + # unzip_service_enabled deliberately absent + }, + } + ) + result = await get_download_config(mock_client) + # _bool_str(None) returns "—" + assert "—" in result diff --git a/tests/modules/downloadstation/test_helpers.py b/tests/modules/downloadstation/test_helpers.py new file mode 100644 index 0000000..2ac7ddd --- /dev/null +++ b/tests/modules/downloadstation/test_helpers.py @@ -0,0 +1,142 @@ +"""Tests for modules/downloadstation/helpers.py — pure-function helpers.""" + +from __future__ import annotations + +import pytest + +from mcp_synology.modules.downloadstation.helpers import ( + format_eta, + format_speed, + format_task_status, + format_transfer_progress, +) + + +class TestFormatScheduleGrid: + def test_all_off(self) -> None: + from mcp_synology.modules.downloadstation.helpers import format_schedule_grid + + plan = "0" * 168 + out = format_schedule_grid(plan) + assert "Sun" in out + assert "Sat" in out + assert "00" in out and "23" in out + + def test_all_on(self) -> None: + from mcp_synology.modules.downloadstation.helpers import format_schedule_grid + + plan = "1" * 168 + out = format_schedule_grid(plan) + # '.' is the off-marker; should not appear in any grid row + # (the legend line is excluded from this check). + grid_lines = [line for line in out.splitlines() if not line.startswith("Legend")] + assert all("." not in line for line in grid_lines) + + def test_mixed_throttle(self) -> None: + from mcp_synology.modules.downloadstation.helpers import format_schedule_grid + + sunday = "0" + "1" + "2" + "0" * 21 + plan = sunday + "0" * (168 - 24) + out = format_schedule_grid(plan) + assert "Sun" in out + + def test_invalid_length_raises(self) -> None: + from mcp_synology.modules.downloadstation.helpers import format_schedule_grid + + with pytest.raises(ValueError, match="168"): + format_schedule_grid("0" * 100) + + +class TestFormatTaskStatus: + @pytest.mark.parametrize( + ("status_code", "expected"), + [ + (1, "waiting"), + (2, "downloading"), + (3, "paused"), + (4, "finishing"), + (5, "finished"), + (6, "hash_checking"), + (7, "seeding"), + (8, "filehosting_waiting"), + (9, "extracting"), + (10, "error"), + ], + ) + def test_known_status_codes(self, status_code: int, expected: str) -> None: + assert format_task_status(status_code) == expected + + def test_unknown_status_code_returns_unknown_with_value(self) -> None: + assert format_task_status(99) == "unknown(99)" + + def test_none_status_returns_unknown(self) -> None: + assert format_task_status(None) == "unknown" + + +class TestFormatTransferProgress: + def test_zero_size(self) -> None: + assert format_transfer_progress(downloaded=0, total=0) == "0 B / 0 B (—)" + + def test_partial(self) -> None: + # 512 MB / 1 GB = exactly 50% (512 * 1024 * 1024 / 1024 * 1024 * 1024) + out = format_transfer_progress(downloaded=512 * 1024 * 1024, total=1024 * 1024 * 1024) + assert "(50%)" in out + # Tightened beyond plan: assert the unit too so a future change to + # format_size's boundary doesn't pass silently. + assert "512 MB" in out + + def test_complete(self) -> None: + out = format_transfer_progress(downloaded=1000, total=1000) + assert "(100%)" in out + + def test_total_smaller_than_downloaded_clamps_to_100(self) -> None: + out = format_transfer_progress(downloaded=2000, total=1000) + assert "(100%)" in out + + +class TestFormatSpeed: + def test_zero_renders_em_dash(self) -> None: + assert format_speed(0) == "—" + + def test_negative_renders_em_dash(self) -> None: + assert format_speed(-100) == "—" + + def test_positive_renders_size_per_sec(self) -> None: + out = format_speed(1024 * 1024) + assert "/s" in out + assert "1" in out # 1 MB rendered as "1 MB" by format_size + + +class TestFormatEta: + def test_zero_speed_returns_em_dash(self) -> None: + assert format_eta(downloaded=0, total=1000, speed=0) == "—" + + def test_negative_speed_returns_em_dash(self) -> None: + assert format_eta(downloaded=0, total=1000, speed=-5) == "—" + + def test_downloaded_equals_total_returns_em_dash(self) -> None: + assert format_eta(downloaded=1000, total=1000, speed=10) == "—" + + def test_downloaded_exceeds_total_returns_em_dash(self) -> None: + # DSM occasionally over-reports during seed-after-finish; should not negative ETA + assert format_eta(downloaded=2000, total=1000, speed=10) == "—" + + def test_under_one_minute_renders_seconds(self) -> None: + # 30 bytes remaining at 1 B/s → 30s + assert format_eta(downloaded=0, total=30, speed=1) == "30s" + + def test_under_one_hour_renders_minutes(self) -> None: + # 600 bytes remaining at 1 B/s → 600s → 10m + assert format_eta(downloaded=0, total=600, speed=1) == "10m" + + def test_under_one_day_renders_hours_minutes(self) -> None: + # 7200s = 2h0m + assert format_eta(downloaded=0, total=7200, speed=1) == "2h0m" + # 7320s = 2h2m + assert format_eta(downloaded=0, total=7320, speed=1) == "2h2m" + + def test_one_day_or_more_renders_days_hours(self) -> None: + # 86400s = exactly 1 day → 1d0h + assert format_eta(downloaded=0, total=86400, speed=1) == "1d0h" + # 90000s = 1d1h + assert format_eta(downloaded=0, total=90000, speed=1) == "1d1h" diff --git a/tests/modules/downloadstation/test_register.py b/tests/modules/downloadstation/test_register.py new file mode 100644 index 0000000..44a9eb0 --- /dev/null +++ b/tests/modules/downloadstation/test_register.py @@ -0,0 +1,139 @@ +"""Tests for modules/downloadstation/__init__.py — module registration code paths. + +Mirrors the structure of tests/modules/filestation/test_register.py. As Phase 1 +adds each tool, this file gains a test asserting the tool is registered when +allowed and absent when filtered out. +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +from mcp.server.fastmcp import FastMCP + +from mcp_synology.modules import RegisterContext +from mcp_synology.modules.downloadstation import MODULE_INFO, register + + +def _make_ctx( + allowed: set[str] | None = None, + settings: dict | None = None, +) -> tuple[FastMCP, MagicMock, RegisterContext]: + server = FastMCP("test-ds") + manager = MagicMock() + fake_client = MagicMock() + manager.get_client = AsyncMock(return_value=fake_client) + manager.with_update_notice = MagicMock(side_effect=lambda s: s) + + if allowed is None: + allowed = {t.name for t in MODULE_INFO.tools} + + ctx = RegisterContext( + server=server, + manager=manager, + allowed_tools=allowed, + settings_dict=settings or {}, + display_name="test-nas", + ) + return server, manager, ctx + + +class TestDownloadstationModuleRegister: + def test_module_info_declares_required_dsm_task_api(self) -> None: + api_names = {req.api_name for req in MODULE_INFO.required_apis} + assert "SYNO.DownloadStation.Task" in api_names + required = [r for r in MODULE_INFO.required_apis if not r.optional] + assert len(required) == 1 + assert required[0].api_name == "SYNO.DownloadStation.Task" + + def test_module_info_phase1_tools_present(self) -> None: + tool_names = {t.name for t in MODULE_INFO.tools} + assert tool_names == { + "list_downloads", + "get_download_info", + "get_download_stats", + "get_download_config", + "get_schedule", + } + + def test_module_info_phase1_tools_are_all_read_tier(self) -> None: + from mcp_synology.modules import PermissionTier + + for tool in MODULE_INFO.tools: + assert tool.permission_tier == PermissionTier.READ, ( + f"Phase 1 tool {tool.name} should be READ tier, got {tool.permission_tier}" + ) + + def test_register_no_tools_when_none_allowed(self) -> None: + server, _manager, ctx = _make_ctx(allowed=set()) + register(ctx) + assert server._tool_manager._tools == {} + + def test_register_all_tools_when_all_allowed(self) -> None: + server, _manager, ctx = _make_ctx() + register(ctx) + registered = set(server._tool_manager._tools.keys()) + expected = {t.name for t in MODULE_INFO.tools} + assert registered == expected + + +class TestDownloadstationToolInvocation: + """Invoke each registered tool to walk the closure body lines. + + Mirrors tests/modules/filestation/test_register.py::TestFilestationToolInvocation. + The downloadstation register() body has one closure per tool; without this + coverage the closures sit at the bottom of __init__.py untested even after + integration tests confirm registration succeeds. + """ + + @staticmethod + def _capture_call(monkeypatch, target: str) -> AsyncMock: + mock = AsyncMock(return_value=f"<<{target}-result>>") + monkeypatch.setattr(target, mock) + return mock + + async def test_list_downloads_invocation(self, monkeypatch) -> None: + server, manager, ctx = _make_ctx() + target = "mcp_synology.modules.downloadstation.tasks.list_downloads" + mock = self._capture_call(monkeypatch, target) + register(ctx) + result = await server._tool_manager._tools["list_downloads"].fn() + assert result == f"<<{target}-result>>" + manager.get_client.assert_awaited() + mock.assert_awaited_once() + + async def test_get_download_info_invocation(self, monkeypatch) -> None: + server, _manager, ctx = _make_ctx() + target = "mcp_synology.modules.downloadstation.tasks.get_download_info" + mock = self._capture_call(monkeypatch, target) + register(ctx) + result = await server._tool_manager._tools["get_download_info"].fn(task_id="dbid_001") + assert result == f"<<{target}-result>>" + mock.assert_awaited_once() + + async def test_get_download_stats_invocation(self, monkeypatch) -> None: + server, _manager, ctx = _make_ctx() + target = "mcp_synology.modules.downloadstation.stats.get_download_stats" + mock = self._capture_call(monkeypatch, target) + register(ctx) + result = await server._tool_manager._tools["get_download_stats"].fn() + assert result == f"<<{target}-result>>" + mock.assert_awaited_once() + + async def test_get_download_config_invocation(self, monkeypatch) -> None: + server, _manager, ctx = _make_ctx() + target = "mcp_synology.modules.downloadstation.config.get_download_config" + mock = self._capture_call(monkeypatch, target) + register(ctx) + result = await server._tool_manager._tools["get_download_config"].fn() + assert result == f"<<{target}-result>>" + mock.assert_awaited_once() + + async def test_get_schedule_invocation(self, monkeypatch) -> None: + server, _manager, ctx = _make_ctx() + target = "mcp_synology.modules.downloadstation.config.get_schedule" + mock = self._capture_call(monkeypatch, target) + register(ctx) + result = await server._tool_manager._tools["get_schedule"].fn() + assert result == f"<<{target}-result>>" + mock.assert_awaited_once() diff --git a/tests/modules/downloadstation/test_stats.py b/tests/modules/downloadstation/test_stats.py new file mode 100644 index 0000000..76b721a --- /dev/null +++ b/tests/modules/downloadstation/test_stats.py @@ -0,0 +1,61 @@ +"""Tests for modules/downloadstation/stats.py — get_download_stats.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import respx + +from mcp_synology.modules.downloadstation.stats import get_download_stats +from tests.conftest import BASE_URL + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + + +class TestGetDownloadStats: + @respx.mock + async def test_renders_total_speeds(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "speed_download": 5242880, + "speed_upload": 1048576, + }, + } + ) + result = await get_download_stats(mock_client) + assert "Download" in result + assert "Upload" in result + assert "eMule" not in result + + @respx.mock + async def test_includes_emule_when_present(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "speed_download": 0, + "speed_upload": 0, + "emule_speed_download": 128 * 1024, + "emule_speed_upload": 64 * 1024, + }, + } + ) + result = await get_download_stats(mock_client) + assert "eMule" in result + + @respx.mock + async def test_dsm_error_propagates_as_tool_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 105}}, + ) + try: + await get_download_stats(mock_client) + except ToolError as e: + assert "105" in str(e) or "permission" in str(e).lower() + else: + raise AssertionError("expected ToolError") diff --git a/tests/modules/downloadstation/test_tasks.py b/tests/modules/downloadstation/test_tasks.py new file mode 100644 index 0000000..974ec64 --- /dev/null +++ b/tests/modules/downloadstation/test_tasks.py @@ -0,0 +1,245 @@ +"""Tests for modules/downloadstation/tasks.py — list_downloads, get_download_info.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import respx + +from mcp_synology.modules.downloadstation.tasks import get_download_info, list_downloads +from tests.conftest import BASE_URL + +if TYPE_CHECKING: + from mcp_synology.core.client import DsmClient + + +class TestListDownloads: + @respx.mock + async def test_lists_all_tasks(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "offset": 0, + "total": 2, + "tasks": [ + { + "id": "dbid_001", + "type": "bt", + "title": "ubuntu-24.04.iso", + "size": 5368709120, + "status": 2, + "additional": { + "detail": {"destination": "downloads"}, + "transfer": { + "size_downloaded": 2684354560, + "speed_download": 5242880, + "speed_upload": 0, + }, + }, + }, + { + "id": "dbid_002", + "type": "http", + "title": "movie.mkv", + "size": 2147483648, + "status": 5, + "additional": { + "detail": {"destination": "video"}, + "transfer": { + "size_downloaded": 2147483648, + "speed_download": 0, + "speed_upload": 0, + }, + }, + }, + ], + }, + } + ) + result = await list_downloads(mock_client, status_filter="all") + assert "ubuntu-24.04.iso" in result + assert "movie.mkv" in result + assert "downloading" in result + assert "finished" in result + assert "(50%)" in result + + @respx.mock + async def test_filter_downloading_excludes_finished(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "offset": 0, + "total": 2, + "tasks": [ + { + "id": "dbid_001", + "type": "bt", + "title": "active.iso", + "size": 100, + "status": 2, + "additional": { + "detail": {}, + "transfer": { + "size_downloaded": 50, + "speed_download": 100, + "speed_upload": 0, + }, + }, + }, + { + "id": "dbid_002", + "type": "http", + "title": "done.mkv", + "size": 100, + "status": 5, + "additional": { + "detail": {}, + "transfer": { + "size_downloaded": 100, + "speed_download": 0, + "speed_upload": 0, + }, + }, + }, + ], + }, + } + ) + result = await list_downloads(mock_client, status_filter="downloading") + assert "active.iso" in result + assert "done.mkv" not in result + + @respx.mock + async def test_empty_queue(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": True, "data": {"offset": 0, "total": 0, "tasks": []}}, + ) + result = await list_downloads(mock_client) + assert "No items to display" in result + + @respx.mock + async def test_dsm_error_propagates_as_tool_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 105}}, + ) + try: + await list_downloads(mock_client) + except ToolError as e: + assert "105" in str(e) or "permission" in str(e).lower() + else: + raise AssertionError("expected ToolError") + + @respx.mock + async def test_unknown_status_filter_raises_tool_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + try: + await list_downloads(mock_client, status_filter="not_a_status") + except ToolError as e: + assert "status_filter" in str(e) + else: + raise AssertionError("expected ToolError for invalid status_filter") + + +class TestGetDownloadInfo: + @respx.mock + async def test_returns_detail_transfer_blocks(self, mock_client: DsmClient) -> None: + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={ + "success": True, + "data": { + "tasks": [ + { + "id": "dbid_001", + "type": "bt", + "title": "ubuntu.iso", + "size": 1000000000, + "status": 2, + "additional": { + "detail": { + "destination": "downloads", + "uri": "magnet:?xt=...", + "create_time": 1700000000, + "started_time": 1700000010, + "priority": "auto", + }, + "transfer": { + "size_downloaded": 500000000, + "size_uploaded": 100000000, + "speed_download": 1024 * 1024, + "speed_upload": 256 * 1024, + }, + "file": [ + { + "filename": "ubuntu.iso", + "size": 1000000000, + "size_downloaded": 500000000, + "priority": "normal", + }, + ], + "tracker": [ + { + "url": "http://tracker.example.org/announce", + "status": "Success", + "peers": 42, + "seeds": 10, + }, + ], + "peer": [ + { + "address": "1.2.3.4", + "agent": "Transmission", + "progress": 1.0, + "speed_download": 0, + "speed_upload": 1024, + }, + ], + }, + } + ] + }, + } + ) + result = await get_download_info(mock_client, task_id="dbid_001") + assert "ubuntu.iso" in result + assert "downloading" in result + assert "downloads" in result # destination + assert "(50%)" in result # transfer progress + # Section headers should all be present. + assert "Files" in result + assert "Trackers" in result + assert "Peers" in result + assert "tracker.example.org" in result + assert "1.2.3.4" in result + + @respx.mock + async def test_task_not_found_error(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": False, "error": {"code": 404}}, + ) + try: + await get_download_info(mock_client, task_id="dbid_missing") + except ToolError as e: + assert "404" in str(e) or "task" in str(e).lower() + else: + raise AssertionError("expected ToolError for missing task") + + @respx.mock + async def test_empty_tasks_array_treated_as_not_found(self, mock_client: DsmClient) -> None: + from mcp.server.fastmcp.exceptions import ToolError + + respx.get(f"{BASE_URL}/webapi/entry.cgi").respond( + json={"success": True, "data": {"tasks": []}}, + ) + try: + await get_download_info(mock_client, task_id="dbid_001") + except ToolError as e: + assert "not found" in str(e).lower() or "dbid_001" in str(e) + else: + raise AssertionError("expected ToolError when DSM returns empty tasks array")