From 83bccbcb5396feb8a06183f7224281e814c69240 Mon Sep 17 00:00:00 2001 From: Marcus van Elst Date: Tue, 14 Apr 2026 15:33:22 +0200 Subject: [PATCH] chore: remove throwaway test scripts, update SPEC.md for v0.2.10 - Delete test_dirsize_md5.py, test_extract.py, test_sharing.py - SPEC.md: add all 20 tools, DSM quirks (one-shot, cold-start, FastMCP outputSchema, DirSize status v1, Sharing.delete id encoding), APIs confirmed unavailable, mark v0.2 complete, list v0.3 candidates Co-Authored-By: Claude Sonnet 4.6 --- SPEC.md | 408 ++++++++++++++++++++++++++++++++------------ test_dirsize_md5.py | 123 ------------- 2 files changed, 295 insertions(+), 236 deletions(-) delete mode 100644 test_dirsize_md5.py diff --git a/SPEC.md b/SPEC.md index c119cf5..5e0a4ff 100644 --- a/SPEC.md +++ b/SPEC.md @@ -62,21 +62,83 @@ All requests use `GET /webapi/entry.cgi` with query parameters unless noted. | `SYNO.FileStation.Rename` | 2 | `rename` | | `SYNO.FileStation.CopyMove` | 3 | `start`, `status`, `stop` | | `SYNO.FileStation.Delete` | 2 | `start`, `status`, `stop` | +| `SYNO.FileStation.Compress` | 3 | `start`, `status`, `stop` | +| `SYNO.FileStation.Extract` | 2 | `start`, `status`, `stop` | +| `SYNO.FileStation.DirSize` | 2/1 | `start` (v2), `status` (v1) | +| `SYNO.FileStation.MD5` | 2/1 | `start` (v2), `status` (v1) | +| `SYNO.FileStation.CheckPermission` | 3 | `write` | +| `SYNO.FileStation.Sharing` | 3 | `create`, `list`, `delete` | -### Async task pattern (CopyMove, Delete, Search) +### Async task pattern (CopyMove, Delete, Search, Compress, Extract) DSM returns a `taskid` from `start`; the client polls `status` with exponential backoff (initial 200 ms, max 2 s, timeout 60 s) until `finished=true`, then calls `stop`/`clean`. +Implemented in `_poll_task()`. + +### One-shot task pattern (DirSize, MD5) + +`DirSize` and `MD5` differ from the async task pattern: DSM delivers `finished=true` exactly +once, then immediately discards the result (subsequent polls return error 599). Implemented +in `_start_and_poll_oneshot()`: + +1. Call `start`. +2. Poll `status` repeatedly until `finished=true` or timeout. +3. On 599: if within the first ~8 s after start, the background service may still be warming + up ("cold start") — restart the task and try again (up to 6 restarts). +4. On `finished=true`: return the data and stop polling (never poll again). + +**Cold-start behaviour:** After a period of inactivity, DSM's DirSize/MD5 background service +takes ~6–8 s to initialise. During this window every `status` poll returns error 599. The +correct recovery is to wait a moment, then restart the task. Retrying the same `taskid` does +not help — the service must be cold-started via a new `start` call. + +**DirSize `status` version:** Must use `version=1`. Using `version=2` always returns 599 +regardless of service state. --- -## Tools +## Tools (v0.2.10 — 20 tools) ### Read-only -#### `list_shares` -List all shared folders visible to the authenticated user. +| Tool | Description | +|------|-------------| +| `list_shares` | List all shared folders with volume usage | +| `list_dir` | List directory contents with pagination and sorting | +| `get_info` | Get detailed metadata for one or more paths | +| `check_exist` | Check if one or more paths exist (Yes/No table) | +| `search` | Search for files by glob pattern with async polling | +| `download` | Download a file as base64 (max 10 MB) | +| `dir_size` | Total size, file count, folder count for directories | +| `get_md5` | Compute MD5 checksum of a file | +### Write + +| Tool | Description | +|------|-------------| +| `create_folder` | Create a new folder (optionally with parent dirs) | +| `rename` | Rename a file or folder | +| `copy` | Copy a file or folder (async polling, overwrite=False default) | +| `move` | Move a file or folder (async polling, overwrite=False default) | +| `delete` | Delete a file or folder — requires confirmed=True | +| `upload` | Upload base64-encoded content to a path (max 50 MB) | +| `compress` | Compress paths into a ZIP or 7z archive | +| `extract` | Extract a ZIP or 7z archive to a destination folder | + +### Permission & Sharing + +| Tool | Description | +|------|-------------| +| `check_permission` | Check write permission for a filename in a directory | +| `create_sharing_link` | Create a public sharing link (optional password + expiry) | +| `list_sharing_links` | List all sharing links (paginated table) | +| `delete_sharing_link` | Delete a sharing link by ID | + +--- + +### Tool details + +#### `list_shares` **Parameters:** none **Returns:** Formatted table of share names, paths, and volume usage. @@ -92,8 +154,6 @@ additional=["volume_status"] --- #### `list_dir` -List contents of a directory with optional pagination and sorting. - **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| @@ -119,17 +179,11 @@ additional=["size","time"] > > **`additional` format:** Must be a JSON array serialised as a string > (`json.dumps(["size","time"])` → `'["size", "time"]'`). A comma-separated string -> (`"size,time"`) is silently ignored by DSM — the `additional` field will be absent from -> every file entry. -> -> **`SYNO.FileStation.Stat`:** Not available on this NAS's API registry. Use -> `SYNO.FileStation.List::getinfo` for per-path metadata instead. +> (`"size,time"`) is silently ignored by DSM. --- #### `get_info` -Get detailed metadata for one or more files or folders. - **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| @@ -140,19 +194,25 @@ creation time, and real volume path for each requested item. **DSM call:** `SYNO.FileStation.List::getinfo` ``` -path={comma-joined paths}, +path=json.dumps([paths...]), additional=["real_path","size","time","perm","owner","type"] ``` -> **Note:** `SYNO.FileStation.Stat` is not available on all NAS firmware versions and is -> absent from this NAS's API registry. `SYNO.FileStation.List::getinfo` returns identical -> data and is confirmed working. +--- + +#### `check_exist` +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | One or more share-relative paths, comma-separated | + +**Returns:** Yes/No table per path. + +**DSM call:** `SYNO.FileStation.List::getinfo` — entries with `name=null` are non-existent. --- #### `search` -Search for files matching a pattern within a directory. - **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| @@ -164,36 +224,48 @@ Search for files matching a pattern within a directory. **Returns:** List of matching paths with type, size, and modification time. **DSM calls:** `SYNO.FileStation.Search::start` → poll `::list` → `::stop` + `::clean` -``` -start: folder_path={path}, recursive={recursive}, pattern={pattern} -list: taskid={taskid}, offset=0, limit={max_results} -``` --- #### `download` -Download a single file and return its content. - **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| -| `path` | str | yes | Absolute path to the file on the NAS | +| `path` | str | yes | Share-relative file path | -**Returns:** Object with `filename`, `size`, `content_base64` (base64-encoded file bytes). -Files larger than 10 MB return an error suggesting `sftp` instead. +**Returns:** JSON `{filename, size, content_base64}`. Files > 10 MB return an error. -**DSM call:** `SYNO.FileStation.Download::download` (streaming GET) -``` -path={path}, mode=download -``` +**DSM call:** `SYNO.FileStation.Download::download` (streaming GET, `mode=download`) --- -### Write (require explicit confirmation where noted) +#### `dir_size` +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | Comma-separated share-relative paths | + +**Returns:** Table with total size, file count, folder count. + +**DSM calls:** `SYNO.FileStation.DirSize::start` (v2) → poll `::status` (v1) + +> See *One-shot task pattern* above for polling behaviour and cold-start recovery. + +--- + +#### `get_md5` +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `path` | str | yes | Share-relative file path | + +**Returns:** `MD5 of {path}: {hash}` + +**DSM calls:** `SYNO.FileStation.MD5::start` (v2) → poll `::status` (v1) + +--- #### `create_folder` -Create a new directory (and optionally all intermediate parents). - **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| @@ -201,113 +273,169 @@ Create a new directory (and optionally all intermediate parents). | `name` | str | yes | — | New folder name | | `create_parents` | bool | no | false | Create missing parent directories | -**Returns:** Path of the created folder or an error message. - **DSM call:** `SYNO.FileStation.CreateFolder::create` -``` -folder_path={path}, name={name}, force_parent={create_parents} -``` --- #### `rename` -Rename a file or directory. - **Parameters:** | Name | Type | Required | Description | |------|------|----------|-------------| -| `path` | str | yes | Absolute path to the item | -| `new_name` | str | yes | New filename (not a full path) | - -**Returns:** New absolute path after rename. +| `path` | str | yes | Current share-relative path | +| `new_name` | str | yes | New filename (bare name, not full path) | **DSM call:** `SYNO.FileStation.Rename::rename` -``` -path={path}, name={new_name} -``` - ---- - -#### `move` -Move a file or directory to a new location. - -**Parameters:** -| Name | Type | Required | Default | Description | -|------|------|----------|---------|-------------| -| `src` | str | yes | — | Source absolute path | -| `dst` | str | yes | — | Destination directory path | -| `overwrite` | bool | no | false | Overwrite if destination exists | - -**Returns:** Destination path on success, or a descriptive error. - -**DSM call:** `SYNO.FileStation.CopyMove::start` (async task) -``` -path={src}, dest_folder_path={dst}, overwrite={overwrite}, remove_src=true -``` --- #### `copy` -Copy a file or directory to a new location. - **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| -| `src` | str | yes | — | Source absolute path | +| `src` | str | yes | — | Source path | | `dst` | str | yes | — | Destination directory path | | `overwrite` | bool | no | false | Overwrite if destination exists | -**Returns:** Destination path on success, or a descriptive error. +**DSM call:** `SYNO.FileStation.CopyMove::start` (async, `remove_src=false`) -**DSM call:** `SYNO.FileStation.CopyMove::start` (async task) -``` -path={src}, dest_folder_path={dst}, overwrite={overwrite}, remove_src=false -``` +--- + +#### `move` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `src` | str | yes | — | Source path | +| `dst` | str | yes | — | Destination directory path | +| `overwrite` | bool | no | false | Overwrite if destination exists | + +**DSM call:** `SYNO.FileStation.CopyMove::start` (async, `remove_src=true`) --- #### `delete` **Destructive — requires `confirmed=True`.** -Delete a file or directory. Without confirmation, returns a preview of what would be deleted. - **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| -| `path` | str | yes | — | Absolute path to delete | +| `path` | str | yes | — | Path to delete | | `confirmed` | bool | yes | false | Must be `true` to proceed | **Returns:** -- `confirmed=false`: Preview message listing the path and item type. -- `confirmed=true`: Success message or error detail. +- `confirmed=false`: Preview of what would be deleted. +- `confirmed=true`: Success message or error. -**DSM call:** `SYNO.FileStation.Delete::start` (async task) -``` -path={path}, recursive=true, accurate_progress=false -``` +**DSM call:** `SYNO.FileStation.Delete::start` (async, `recursive=true`) --- #### `upload` -Upload a file to the NAS from base64-encoded content. - **Parameters:** | Name | Type | Required | Default | Description | |------|------|----------|---------|-------------| -| `path` | str | yes | — | Destination directory path on the NAS | +| `path` | str | yes | — | Destination directory path | | `filename` | str | yes | — | Filename to create | | `content_base64` | str | yes | — | Base64-encoded file content | -| `overwrite` | bool | no | false | Overwrite if a file with this name already exists | +| `overwrite` | bool | no | false | Overwrite if file exists | | `create_parents` | bool | no | true | Create missing parent directories | -**Returns:** Full path of the uploaded file or an error message. -Files exceeding 50 MB should not be uploaded via MCP; return a clear error. +**Returns:** Full path of uploaded file. Files > 50 MB are rejected. **DSM call:** `SYNO.FileStation.Upload::upload` (POST multipart/form-data) -``` -path={path}, create_parents={create_parents}, overwrite={overwrite}, -file= -``` + +--- + +#### `compress` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `paths` | list[str] | yes | — | Paths to compress | +| `dest_file_path` | str | yes | — | Output archive path incl. filename | +| `level` | str | no | `moderate` | `store`/`fastest`/`fast`/`normal`/`moderate`/`maximum` | +| `mode` | str | no | `add` | `add`/`update`/`refreshen` | +| `format` | str | no | `zip` | `zip` or `7z` | +| `password` | str | no | `""` | Archive password | + +**DSM call:** `SYNO.FileStation.Compress::start` (async, v3) + +--- + +#### `extract` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `file_path` | str | yes | — | Archive path on NAS | +| `dest_folder_path` | str | yes | — | Destination directory | +| `overwrite` | bool | no | false | Overwrite existing files | +| `keep_dir` | bool | no | true | Preserve directory structure | +| `create_subfolder` | bool | no | false | Extract into a subfolder | +| `password` | str | no | `""` | Archive password | + +**DSM call:** `SYNO.FileStation.Extract::start` (async, v2) + +--- + +#### `check_permission` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | Directory to check | +| `filename` | str | yes | — | Filename to check write access for | +| `overwrite` | bool | no | false | Check overwrite permission | +| `create_only` | bool | no | false | Check create-only permission | + +**Returns:** `"Permission granted: write {filename} into {path}"` or `"Error: …"` + +**DSM call:** `SYNO.FileStation.CheckPermission::write` (v3) + +> **Parameter format:** `path` and `filename` are passed as plain strings (no `json.dumps`). +> On success, DSM returns `{"blSkip": false}` — the tool maps this to a human-readable message. + +--- + +#### `create_sharing_link` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `path` | str | yes | — | File or folder path to share | +| `password` | str | no | `""` | Password to protect the link | +| `date_expired` | str | no | `""` | Expiry date (`YYYY-MM-DD`) | +| `date_available` | str | no | `""` | Availability start date (`YYYY-MM-DD`) | + +**Returns:** Sharing URL + link ID, with password-protection flag. + +**DSM call:** `SYNO.FileStation.Sharing::create` (v3) + +> `path` must be `json.dumps()`-encoded. The `date_expired`/`date_available` fields are not +> echoed back in the create response — confirm via `list_sharing_links` if needed. + +--- + +#### `list_sharing_links` +**Parameters:** +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `offset` | int | no | 0 | Pagination offset | +| `limit` | int | no | 100 | Max links to return | + +**Returns:** Table with ID, URL, path, owner, expiry, status. Includes total count and +pagination hint when more results are available. + +**DSM call:** `SYNO.FileStation.Sharing::list` (v3) + +--- + +#### `delete_sharing_link` +**Parameters:** +| Name | Type | Required | Description | +|------|------|----------|-------------| +| `link_id` | str | yes | Sharing link ID (from `list_sharing_links`) | + +**Returns:** `"Deleted sharing link: {link_id}"` or error. + +**DSM call:** `SYNO.FileStation.Sharing::delete` (v3) + +> `link_id` must be passed as `json.dumps(link_id)` in the `id` parameter. --- @@ -317,8 +445,7 @@ file= 1. DSM error codes are mapped to human-readable messages before surfacing to Claude. 2. Never expose raw stack traces or session IDs in tool responses. 3. Auth errors (codes 400–403) trigger a clear message with a hint to run `setup`. -4. Session expiry errors (106, 107, 119) are retried once transparently; if the retry also - fails, the user sees "Session expired — please restart the MCP server." +4. Session expiry errors (106, 107, 119) are retried once transparently. 5. Network errors (timeouts, connection refused) are reported as "Cannot reach NAS at {host} — check connectivity." 6. Unknown DSM error codes are reported as "DSM error {code}: {raw_message}". @@ -340,6 +467,7 @@ file= | 403 | 2FA required | "Two-factor authentication required — run setup." | | 404 | 2FA failed | "OTP code incorrect." | | 408 | Device token required | "Device token required — run setup again." | +| 599 | Background service not ready | (handled by `_start_and_poll_oneshot` — restart task) | | 1800 | File not found | "File or folder not found: {path}" | | 1801 | No write permission | "No write permission for: {path}" | | 1802 | File exists | "A file already exists at this path." | @@ -351,6 +479,59 @@ file= --- +## DSM API Quirks + +### Parameter encoding + +- **JSON array parameters** (`path`, `additional`, etc.) must be serialised with + `json.dumps()`. A plain Python list or comma-separated string is silently ignored + by DSM — the field will be absent from the response. +- **`path` for multi-item operations** (e.g. `List::getinfo`, `DirSize::start`): + always pass as `json.dumps(["/path1", "/path2"])`, even for a single path. +- **`path` for single-item operations** (e.g. `CopyMove::start`, `Rename::rename`, + `Sharing::create`): pass as `json.dumps("/path")` (a JSON-encoded string). +- **`Sharing::delete` `id` parameter:** must be `json.dumps(link_id)` — plain string + is not accepted. +- **`CheckPermission::write` `path` and `filename`:** pass as plain strings (no + `json.dumps`). +- **Share paths vs. volume paths:** always use share paths (`/docker`, `/homes/marcus`). + Volume paths (`/volume1/docker`) cause DSM error 408. + +### APIs confirmed NOT available on this NAS + +| API | Status | Workaround | +|-----|--------|------------| +| `SYNO.FileStation.Stat` | Not in API registry | Use `List::getinfo` | +| `SYNO.FileStation.CheckExist` | Returns error 400 | Use `List::getinfo` (entries with `name=null` don't exist) | + +### FastMCP `outputSchema` / tools/list payload limit + +Do **not** add `-> str` return type annotations to `@mcp.tool()` functions. FastMCP +infers `outputSchema` from the annotation, which doubles the tools/list payload size. +Claude Desktop truncates the tool list when the payload exceeds its limit, making some +tools invisible. Omitting the return annotation suppresses `outputSchema` generation. + +### DirSize / MD5 one-shot behaviour + +`DirSize` and `MD5` tasks are consumed on first read: `finished=true` is returned exactly +once, then the task is deleted and all subsequent `status` polls return error 599. +Implementation: `_start_and_poll_oneshot()` — never call `status` a second time after +receiving `finished=true`. + +### DirSize cold-start + +After a period of inactivity, DSM's DirSize background service needs ~6–8 s to initialise. +During cold start, `status` returns 599 on every poll. The fix is to wait briefly and +**restart** the task (new `start` call) — polling the original `taskid` again does not help. +`_start_and_poll_oneshot()` retries up to 6 task restarts before giving up. + +### DirSize `status` API version + +`SYNO.FileStation.DirSize::status` must be called with `version=1`. Using `version=2` +always returns error 599 regardless of whether the background service is running. + +--- + ## Configuration Model ```yaml @@ -403,26 +584,27 @@ Load config and credentials, create `FileStationClient` (lazy — no immediate c create and run `FastMCP("mcp-synology-filestation")` over stdio. Uses `anyio.run()` for Windows compatibility. - --- -## Known Limitations +## Roadmap -### `dir_size` / `get_md5`: occasional "DSM error code 599" on large directories +### v0.2 — complete (20 tools) -DSM's `DirSize` and `MD5` APIs are one-shot: once `finished=true` is returned -by the status endpoint, the task is removed and all subsequent polls return -error 599. The MCP server polls immediately after `start` (no initial delay) -and retries up to 5 consecutive 599 responses before giving up. +All tools shipped in v0.2.10: -For small directories and files the result is reliably read on the first or -second poll. For large directories (e.g. `/docker`, `/music`) the task takes -longer; if DSM removes the completed result between two polls the tool returns -`Error: DSM error code 599`. Retrying the operation usually succeeds. +**Group 1:** `list_shares`, `list_dir`, `get_info`, `check_exist`, `search`, `download` +**Group 2:** `create_folder`, `rename`, `copy`, `move`, `delete`, `upload`, `compress`, +`extract`, `dir_size`, `get_md5` +**Group 3:** `check_permission`, `create_sharing_link`, `list_sharing_links`, +`delete_sharing_link` -Root cause is not fully understood. The raw HTTP test script (`test_dirsize_md5.py`) -reliably catches `finished=true` for the same paths, suggesting the issue is -related to timing in the MCP stdio event loop. +### v0.3 — candidates -**Workaround:** retry the `dir_size` call. It succeeds on the second or third attempt -for most paths. +| Tool | API | Notes | +|------|-----|-------| +| `get_thumbnail` | `SYNO.FileStation.Thumb` | Return base64 thumbnail for image/video | +| `list_favorites` | `SYNO.FileStation.Favorite` | List user-pinned favourite paths | +| `add_favorite` | `SYNO.FileStation.Favorite` | Pin a path as favourite | +| `delete_favorite` | `SYNO.FileStation.Favorite` | Remove a favourite | +| `list_snapshots` | `SYNO.FileStation.Snapshot` | List Btrfs snapshots for a share | +| `background_tasks` | `SYNO.FileStation.BackgroundTask` | List/cancel background operations | diff --git a/test_dirsize_md5.py b/test_dirsize_md5.py deleted file mode 100644 index 5455b8a..0000000 --- a/test_dirsize_md5.py +++ /dev/null @@ -1,123 +0,0 @@ -"""Wegwerfskript: DirSize + MD5 direkt gegen die NAS testen. - -Ausfuehren: uv run python test_dirsize_md5.py -""" - -import asyncio -import json -import time - -import httpx - -from mcp_synology_filestation.auth import AuthManager -from mcp_synology_filestation.client import FileStationClient -from mcp_synology_filestation.config import load_config - -DIRSIZE_PATHS = ["/test-mcp", "/docker"] -MD5_PATH = "/test-mcp/test.zip" - - -def pp(label: str, data: object, elapsed_ms: float | None = None) -> None: - print(f"\n{'='*60}") - suffix = f" [{elapsed_ms:.1f} ms]" if elapsed_ms is not None else "" - print(f" {label}{suffix}") - print("=" * 60) - print(json.dumps(data, indent=2, ensure_ascii=False)) - - -async def raw(http: httpx.AsyncClient, url: str, sid: str, **params) -> dict: - r = await http.get(url, params={"_sid": sid, **params}) - r.raise_for_status() - try: - return r.json() - except Exception: - return {"_raw": r.text[:300], "_http_status": r.status_code} - - -async def probe_dirsize_long( - http: httpx.AsyncClient, sid: str, api_url: str, path: str -) -> None: - """Start DirSize and poll v1 every 200ms for up to 15s. - - Goal: find out if 599 means 'task still running' (keep polling) - or 'task gone' (give up). If the task eventually returns data, - 599 = 'not ready yet'. If it never returns data, 599 = 'task gone'. - """ - print(f"\n{'#'*60}") - print(f" DIRSIZE {path} — long poll (15s, every 200ms)") - print(f"{'#'*60}") - - t0 = time.perf_counter() - start_body = await raw( - http, api_url, sid, - api="SYNO.FileStation.DirSize", version="2", method="start", - path=json.dumps([path]), - ) - elapsed_start = (time.perf_counter() - t0) * 1000 - pp(f"DirSize::start ({path})", start_body, elapsed_start) - - taskid = (start_body.get("data") or {}).get("taskid") - if not taskid: - print("[!] No taskid.") - return - - print(f"\n[*] Polling status v1 every 200ms for up to 15s (taskid={taskid[:12]}...)") - for attempt in range(75): # 75 * 200ms = 15s - if attempt > 0: - await asyncio.sleep(0.2) - t = time.perf_counter() - r = await raw( - http, api_url, sid, - api="SYNO.FileStation.DirSize", version="1", method="status", - taskid=taskid, - ) - elapsed = (t - t0) * 1000 - success = r.get("success") - data = (r.get("data") or {}) - finished = data.get("finished") - error_code = (r.get("error") or {}).get("code") - - if finished: - print(f" [{elapsed:.0f}ms] attempt {attempt+1}: FERTIG! " - f"num_dir={data.get('num_dir')} " - f"num_file={data.get('num_file')} " - f"total_size={data.get('total_size')}") - pp(f"DirSize::status final ({path})", r, elapsed) - return - elif success and not finished: - # Still running — show current progress - print(f" [{elapsed:.0f}ms] attempt {attempt+1}: running... " - f"num_dir={data.get('num_dir', '?')} " - f"num_file={data.get('num_file', '?')} " - f"total_size={data.get('total_size', '?')}") - else: - print(f" [{elapsed:.0f}ms] attempt {attempt+1}: error code={error_code}") - # Continue polling — 599 might mean 'not ready yet' - - print(f"\n[!] No result after 15s — task never returned data.") - - -async def main() -> None: - config = load_config() - auth = AuthManager(config) - - async with FileStationClient(config.base_url, config.connection.verify_ssl) as client: - client.set_auth_manager(auth) - await client._ensure_initialized() # noqa: SLF001 - sid = client.sid - base = config.base_url - - info = client._api_cache.get("SYNO.FileStation.DirSize", {}) # noqa: SLF001 - api_url = f"{base}/webapi/{info.get('path', 'entry.cgi')}" - print(f"[*] DirSize API: {api_url} v{info.get('minVersion')}-v{info.get('maxVersion')}") - - async with httpx.AsyncClient(verify=config.connection.verify_ssl, timeout=30.0) as http: - for path in DIRSIZE_PATHS: - await probe_dirsize_long(http, sid, api_url, path) - - await auth.logout(client) - print("\n[*] Logout OK.") - - -if __name__ == "__main__": - asyncio.run(main())