Files
mcphub/plugins/wordpress/handlers/media_chunked.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

323 lines
12 KiB
Python

"""Chunked media upload tools (F.5a.5).
Exposes four tools that wrap `core.upload_sessions.UploadSessionStore`:
- `upload_media_chunked_start(filename, total_bytes, mime?, sha256?)`
- `upload_media_chunked_chunk(session_id, index, data_b64)`
- `upload_media_chunked_finish(session_id, title?, alt_text?, caption?,
attach_to_post?, set_featured?, skip_optimize?)`
- `upload_media_chunked_abort(session_id)`
At `finish`, reuses the existing F.5a.1/.2 primitives: assembled bytes →
optional Pillow optimization → `wp_raw_upload` → metadata/attach/featured.
"""
from __future__ import annotations
import base64 as _b64
import binascii
import json
from typing import Any
from core.media_audit import log_media_upload
from core.upload_sessions import (
UploadSessionError,
UploadSessionStore,
get_upload_session_store,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers._media_core import wp_raw_upload
from plugins.wordpress.handlers._media_security import UploadError
from plugins.wordpress.handlers.media import (
_apply_metadata_and_attach,
_format_upload_result,
_maybe_optimize,
)
def get_tool_specifications() -> list[dict[str, Any]]:
"""Return tool specs for ToolGenerator."""
return [
{
"name": "upload_media_chunked_start",
"method_name": "upload_media_chunked_start",
"description": (
"Start a chunked upload session for a large media file. "
"Returns a session_id to use for subsequent chunk/finish/abort "
"calls. TTL 1h, hard cap 500 MB per session."
),
"schema": {
"type": "object",
"properties": {
"filename": {"type": "string", "description": "Filename with extension."},
"total_bytes": {
"type": "integer",
"minimum": 1,
"description": "Declared total size in bytes.",
},
"mime": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "MIME hint; still sniffed at finish.",
},
"sha256": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Optional hex sha256 of the full payload; verified at finish.",
},
},
"required": ["filename", "total_bytes"],
},
"scope": "write",
},
{
"name": "upload_media_chunked_chunk",
"method_name": "upload_media_chunked_chunk",
"description": (
"Append a single base64-encoded chunk to a chunked upload session. "
"Chunks must arrive in order starting at index 0."
),
"schema": {
"type": "object",
"properties": {
"session_id": {"type": "string"},
"index": {"type": "integer", "minimum": 0},
"data_b64": {
"type": "string",
"description": "Base64-encoded chunk bytes.",
},
"chunk_sha256": {
"anyOf": [{"type": "string"}, {"type": "null"}],
"description": "Optional hex sha256 of this chunk.",
},
},
"required": ["session_id", "index", "data_b64"],
},
"scope": "write",
},
{
"name": "upload_media_chunked_finish",
"method_name": "upload_media_chunked_finish",
"description": (
"Finalize a chunked upload: assemble + optimize + upload to "
"WordPress. Verifies sha256 if supplied at start."
),
"schema": {
"type": "object",
"properties": {
"session_id": {"type": "string"},
"title": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"alt_text": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"caption": {"anyOf": [{"type": "string"}, {"type": "null"}]},
"attach_to_post": {
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"set_featured": {"type": "boolean", "default": False},
"skip_optimize": {"type": "boolean", "default": False},
},
"required": ["session_id"],
},
"scope": "write",
},
{
"name": "upload_media_chunked_abort",
"method_name": "upload_media_chunked_abort",
"description": "Abort a chunked upload session and delete its spill file.",
"schema": {
"type": "object",
"properties": {
"session_id": {"type": "string"},
},
"required": ["session_id"],
},
"scope": "write",
},
{
"name": "upload_media_chunked_status",
"method_name": "upload_media_chunked_status",
"description": (
"Query the current state of a chunked-upload session — returns "
"``received_bytes`` and ``next_chunk`` so the caller can resume "
"after a disconnect. The session survives for 1 h after the "
"last activity; re-start the session with ``upload_media_chunked_start`` "
"if it has expired or been aborted. Returns ``{error_code: "
"NOT_FOUND}`` when the session is unknown."
),
"schema": {
"type": "object",
"properties": {
"session_id": {
"type": "string",
"description": "Session ID returned by upload_media_chunked_start.",
},
},
"required": ["session_id"],
},
"scope": "read",
},
]
def _decode_chunk(data_b64: str) -> bytes:
s = (data_b64 or "").strip()
if s.startswith("data:") and "," in s:
s = s.split(",", 1)[1]
s = s.replace("\n", "").replace("\r", "").replace(" ", "")
try:
return _b64.b64decode(s, validate=True)
except (binascii.Error, ValueError) as e:
raise UploadSessionError("BAD_BASE64", f"Invalid base64 chunk: {e}") from e
class MediaChunkedHandler:
"""Chunked-upload tool handler for the WordPress plugin."""
def __init__(
self,
client: WordPressClient,
*,
user_id: str | None = None,
store: UploadSessionStore | None = None,
) -> None:
self.client = client
self.user_id = user_id or "admin"
self._store = store
@property
def store(self) -> UploadSessionStore:
return self._store or get_upload_session_store()
async def upload_media_chunked_start(
self,
filename: str,
total_bytes: int,
mime: str | None = None,
sha256: str | None = None,
) -> str:
try:
sess = await self.store.start(
user_id=self.user_id,
filename=filename,
total_bytes=total_bytes,
mime=mime,
sha256=sha256,
)
return json.dumps(sess.to_public_dict(), indent=2)
except UploadSessionError as e:
return json.dumps(e.to_dict(), indent=2)
async def upload_media_chunked_chunk(
self,
session_id: str,
index: int,
data_b64: str,
chunk_sha256: str | None = None,
) -> str:
try:
data = _decode_chunk(data_b64)
sess = await self.store.append_chunk(session_id, index, data, chunk_sha256=chunk_sha256)
return json.dumps(sess.to_public_dict(), indent=2)
except UploadSessionError as e:
return json.dumps(e.to_dict(), indent=2)
async def upload_media_chunked_finish(
self,
session_id: str,
title: str | None = None,
alt_text: str | None = None,
caption: str | None = None,
attach_to_post: int | None = None,
set_featured: bool = False,
skip_optimize: bool = False,
) -> str:
from core.tool_rate_limiter import ToolRateLimitError, get_tool_rate_limiter
try:
get_tool_rate_limiter().check(
"wordpress_upload_media_chunked_finish",
self.user_id if self.user_id != "admin" else None,
)
except ToolRateLimitError as e:
return json.dumps(e.to_dict(), indent=2)
try:
sess, assembled = await self.store.finalize(session_id)
data, mime_hint = _maybe_optimize(assembled, sess.mime, skip=skip_optimize)
media = await wp_raw_upload(
self.client,
data,
filename=sess.filename,
mime_hint=mime_hint or sess.mime,
# F.5a.8.5: single-call upload+metadata+attach+featured
# when the companion advertises it.
attach_to_post=attach_to_post,
set_featured=set_featured,
title=title,
alt_text=alt_text,
caption=caption,
)
await _apply_metadata_and_attach(
self.client,
media,
title=title,
alt_text=alt_text,
caption=caption,
attach_to_post=attach_to_post,
set_featured=set_featured,
)
log_media_upload(
site=self.client.site_url,
user_id=self.user_id if self.user_id != "admin" else None,
mime=media.get("mime_type") or mime_hint or sess.mime,
size_bytes=len(data),
source="chunked",
media_id=media.get("id"),
)
return json.dumps(_format_upload_result(media, source="chunked"), indent=2)
except UploadSessionError as e:
return json.dumps(e.to_dict(), indent=2)
except UploadError as e:
return json.dumps(e.to_dict(), indent=2)
except Exception as e: # noqa: BLE001
return json.dumps(
{"error_code": "INTERNAL", "message": f"Chunked finish failed: {e}"}, indent=2
)
async def upload_media_chunked_status(self, session_id: str) -> str:
"""Return the current received_bytes / next_chunk for a session.
Enables a resume-after-disconnect flow: the client keeps the
session_id from the original ``start`` call, then queries this
tool to discover how many bytes the server already has before
resuming ``chunk`` calls at the reported ``next_chunk`` index.
Aborted sessions (spill file gone) still return their last
known status, clearly marked by the ``status`` field.
"""
try:
sess = await self.store.get(session_id)
if sess is None:
return json.dumps(
{
"error_code": "NO_SESSION",
"message": (
"Session not found. Either it has never existed, "
"already been finalised, or been aborted. Start a "
"new session with upload_media_chunked_start."
),
"session_id": session_id,
},
indent=2,
)
return json.dumps(sess.to_public_dict(), indent=2)
except Exception as exc: # noqa: BLE001
return json.dumps(
{
"error_code": "INTERNAL",
"message": f"Status lookup failed: {exc}",
"session_id": session_id,
},
indent=2,
)
async def upload_media_chunked_abort(self, session_id: str) -> str:
removed = await self.store.abort(session_id)
return json.dumps({"session_id": session_id, "aborted": bool(removed)}, indent=2)