Separate OnlyOffice async handling, add office_previews WS flag, framed worker protocol
- Move OnlyOffice conversion out of worker subprocesses into async event loop using httpx.AsyncClient with shared client and clean shutdown hook - Add OOConversionManager with in-flight deduplication (asyncio.Future) and configurable concurrency limit (OO_MAX_CONCURRENT=2) - Add 10s total timeout for office previews via asyncio.wait_for - Return 503 when OnlyOffice is unavailable, 504 on timeout - Remove office handling from worker dispatch(); workers now only do images, video, and PDFs - Replace PNG tempfile bridge with framed binary input protocol on worker stdin: (json_size)(data_size)(json)(raw_data) - Add process_image_buffer() for in-memory AVIF conversion via pyvips - Add office_previews to WS server message, cached every 30s from OO availability check - Frontend gates only office document previews on office_previews flag; images, video, PDFs remain unconditional - Change default OnlyOffice port from 8080 to 8988
This commit is contained in:
@@ -55,6 +55,8 @@ async def watch(req, ws):
|
|||||||
"privileged": req.ctx.user.privileged,
|
"privileged": req.ctx.user.privileged,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
from cista import onlyoffice
|
||||||
|
|
||||||
await ws.send(
|
await ws.send(
|
||||||
msgspec.json.encode(
|
msgspec.json.encode(
|
||||||
{
|
{
|
||||||
@@ -63,6 +65,7 @@ async def watch(req, ws):
|
|||||||
"version": __version__,
|
"version": __version__,
|
||||||
"public": config.config.public,
|
"public": config.config.public,
|
||||||
"paskia": sso.paskia_enabled(),
|
"paskia": sso.paskia_enabled(),
|
||||||
|
"office_previews": await onlyoffice.is_available_cached(),
|
||||||
},
|
},
|
||||||
"user": user_info,
|
"user": user_info,
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ from setproctitle import setproctitle
|
|||||||
from stream_zip import ZIP_AUTO, stream_zip
|
from stream_zip import ZIP_AUTO, stream_zip
|
||||||
from zstandard import ZstdCompressor
|
from zstandard import ZstdCompressor
|
||||||
|
|
||||||
from cista import auth, config, fileserver, preview, session, sharefs, sso, watching
|
from cista import auth, config, fileserver, onlyoffice, preview, session, sharefs, sso, watching
|
||||||
from cista.api import bp
|
from cista.api import bp
|
||||||
from cista.preview import shutdown_preview_workers, start_preview_workers
|
from cista.preview import shutdown_preview_workers, start_preview_workers
|
||||||
from cista.sanic_logging import (
|
from cista.sanic_logging import (
|
||||||
@@ -130,6 +130,7 @@ async def main_start(app):
|
|||||||
@app.before_server_stop
|
@app.before_server_stop
|
||||||
async def main_stop(app):
|
async def main_stop(app):
|
||||||
watching.stop(app)
|
watching.stop(app)
|
||||||
|
await onlyoffice.close_oo_client()
|
||||||
await shutdown_preview_workers()
|
await shutdown_preview_workers()
|
||||||
app.ctx.threadexec.shutdown()
|
app.ctx.threadexec.shutdown()
|
||||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||||
|
|||||||
+122
-4
@@ -10,6 +10,7 @@ Environment requirements:
|
|||||||
reachable from the container (usually the docker bridge IP).
|
reachable from the container (usually the docker bridge IP).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -23,6 +24,7 @@ from pathlib import Path
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
from sanic.log import logger
|
from sanic.log import logger
|
||||||
|
|
||||||
@@ -31,8 +33,11 @@ from sanic.log import logger
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
_httpx_client: httpx.AsyncClient | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice_url() -> str:
|
def _get_onlyoffice_url() -> str:
|
||||||
return os.environ.get("ONLYOFFICE_URL", "http://localhost:8080")
|
return os.environ.get("ONLYOFFICE_URL", "http://localhost:8988")
|
||||||
|
|
||||||
|
|
||||||
def _get_jwt_secret() -> str | None:
|
def _get_jwt_secret() -> str | None:
|
||||||
@@ -62,6 +67,27 @@ def _get_callback_host() -> str:
|
|||||||
return "127.0.0.1"
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Async HTTP client
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_httpx_client() -> httpx.AsyncClient:
|
||||||
|
"""Return the shared async HTTP client for OnlyOffice requests."""
|
||||||
|
global _httpx_client
|
||||||
|
if _httpx_client is None:
|
||||||
|
_httpx_client = httpx.AsyncClient()
|
||||||
|
return _httpx_client
|
||||||
|
|
||||||
|
|
||||||
|
async def close_oo_client() -> None:
|
||||||
|
"""Close the shared async HTTP client."""
|
||||||
|
global _httpx_client
|
||||||
|
if _httpx_client is not None:
|
||||||
|
await _httpx_client.aclose()
|
||||||
|
_httpx_client = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Availability check
|
# Availability check
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -69,14 +95,42 @@ def _get_callback_host() -> str:
|
|||||||
|
|
||||||
def is_available() -> bool:
|
def is_available() -> bool:
|
||||||
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
||||||
url = _get_onlyoffice_url()
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
try:
|
try:
|
||||||
with urllib.request.urlopen(url, timeout=3) as resp: # noqa: S310
|
with urllib.request.urlopen(url, timeout=3) as resp: # noqa: S310
|
||||||
return resp.status == 200
|
return resp.status in (200, 405) # 405 Method Not Allowed is fine, means endpoint exists
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_async(timeout: float = 2.0) -> bool:
|
||||||
|
"""Return True if the configured OnlyOffice Document Server is reachable."""
|
||||||
|
url = _get_onlyoffice_url().rstrip("/") + "/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
try:
|
||||||
|
response = await client.get(url, timeout=timeout)
|
||||||
|
return response.status_code in (200, 405)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
_oo_available_cache: tuple[bool, float] | None = None
|
||||||
|
OO_AVAILABILITY_CACHE_TTL = 30.0
|
||||||
|
|
||||||
|
|
||||||
|
async def is_available_cached() -> bool:
|
||||||
|
"""Return cached OnlyOffice availability, refreshed every 30 seconds."""
|
||||||
|
global _oo_available_cache
|
||||||
|
now = perf_counter()
|
||||||
|
if _oo_available_cache is not None:
|
||||||
|
result, timestamp = _oo_available_cache
|
||||||
|
if now - timestamp < OO_AVAILABILITY_CACHE_TTL:
|
||||||
|
return result
|
||||||
|
result = await is_available_async()
|
||||||
|
_oo_available_cache = (result, now)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Temporary HTTP server so OnlyOffice can download the file
|
# Temporary HTTP server so OnlyOffice can download the file
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -121,7 +175,7 @@ def _build_jwt_token(payload: dict) -> str | None:
|
|||||||
return jwt.encode(payload, secret, algorithm="HS256")
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
|
def convert_to_png(file_path: Path, timeout: float = 5.0) -> bytes:
|
||||||
"""Convert *file_path* to PNG using OnlyOffice Document Server.
|
"""Convert *file_path* to PNG using OnlyOffice Document Server.
|
||||||
|
|
||||||
Returns the PNG bytes. Raises RuntimeError on failure.
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
@@ -182,3 +236,67 @@ def convert_to_png(file_path: Path, timeout: float = 30.0) -> bytes:
|
|||||||
return png_resp.read()
|
return png_resp.read()
|
||||||
finally:
|
finally:
|
||||||
httpd.shutdown()
|
httpd.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
async def convert_to_png_async(file_path: Path, timeout: float = 5.0) -> bytes:
|
||||||
|
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
||||||
|
|
||||||
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
|
"""
|
||||||
|
oo_url = _get_onlyoffice_url().rstrip("/")
|
||||||
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
|
client = get_httpx_client()
|
||||||
|
|
||||||
|
# Start temporary HTTP server so OnlyOffice can fetch the file
|
||||||
|
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
|
||||||
|
try:
|
||||||
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
|
payload = {
|
||||||
|
"async": False,
|
||||||
|
"filetype": suffix,
|
||||||
|
"key": f"cista_{file_path.stat().st_mtime_ns}",
|
||||||
|
"outputtype": "png",
|
||||||
|
"title": file_path.name,
|
||||||
|
"url": doc_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
token = _build_jwt_token(payload)
|
||||||
|
if token:
|
||||||
|
# Conversion API expects JWT in request body when token checks are enabled.
|
||||||
|
payload["token"] = token
|
||||||
|
headers["Authorization"] = token
|
||||||
|
|
||||||
|
t_start = perf_counter()
|
||||||
|
response = await client.post(
|
||||||
|
convert_url,
|
||||||
|
content=json.dumps(payload).encode(),
|
||||||
|
headers=headers,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
body = response.content
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
# Parse XML response
|
||||||
|
text = body.decode("utf-8", errors="replace")
|
||||||
|
if "<Error>" in text:
|
||||||
|
code = "unknown"
|
||||||
|
if "<Error>" in text and "</Error>" in text:
|
||||||
|
code = text.split("<Error>")[1].split("</Error>")[0]
|
||||||
|
raise RuntimeError(f"OnlyOffice conversion error: {code}")
|
||||||
|
|
||||||
|
if "<FileUrl>" not in text:
|
||||||
|
raise RuntimeError("OnlyOffice response did not contain FileUrl")
|
||||||
|
|
||||||
|
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
|
||||||
|
file_url = file_url.replace("&", "&")
|
||||||
|
|
||||||
|
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
||||||
|
|
||||||
|
# Download converted PNG
|
||||||
|
png_response = await client.get(file_url, timeout=timeout)
|
||||||
|
png_response.raise_for_status()
|
||||||
|
return png_response.content
|
||||||
|
finally:
|
||||||
|
await asyncio.to_thread(httpd.shutdown)
|
||||||
|
|||||||
+139
-17
@@ -10,7 +10,7 @@ import urllib.parse
|
|||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from multiprocessing import cpu_count
|
from multiprocessing import cpu_count
|
||||||
from pathlib import PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
from wsgiref.handlers import format_date_time
|
from wsgiref.handlers import format_date_time
|
||||||
@@ -112,14 +112,15 @@ class _PreviewWorker:
|
|||||||
def __init__(self, proc: asyncio.subprocess.Process):
|
def __init__(self, proc: asyncio.subprocess.Process):
|
||||||
self.proc = proc
|
self.proc = proc
|
||||||
|
|
||||||
async def request(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
async def request(
|
||||||
|
self, filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
|
||||||
|
):
|
||||||
if self.proc.returncode is not None:
|
if self.proc.returncode is not None:
|
||||||
raise WorkerProtocolError("worker already exited")
|
raise WorkerProtocolError("worker already exited")
|
||||||
if self.proc.stdin is None or self.proc.stdout is None:
|
if self.proc.stdin is None or self.proc.stdout is None:
|
||||||
raise WorkerProtocolError("worker streams not available")
|
raise WorkerProtocolError("worker streams not available")
|
||||||
|
|
||||||
line = (
|
meta = msgspec.json.encode(
|
||||||
msgspec.json.encode(
|
|
||||||
PreviewRequest(
|
PreviewRequest(
|
||||||
path=str(filepath),
|
path=str(filepath),
|
||||||
quality=quality,
|
quality=quality,
|
||||||
@@ -127,9 +128,9 @@ class _PreviewWorker:
|
|||||||
maxzoom=maxzoom,
|
maxzoom=maxzoom,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
+ b"\n"
|
payload = data or b""
|
||||||
)
|
packet = struct.pack("<II", len(meta), len(payload)) + meta + payload
|
||||||
self.proc.stdin.write(line)
|
self.proc.stdin.write(packet)
|
||||||
await self.proc.stdin.drain()
|
await self.proc.stdin.drain()
|
||||||
|
|
||||||
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
checksum = await self.proc.stdout.readexactly(WORKER_CHECKSUM_BYTES)
|
||||||
@@ -270,7 +271,9 @@ class _PreviewWorkerPool:
|
|||||||
for _ in range(self.size):
|
for _ in range(self.size):
|
||||||
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
|
self._dispatchers.append(asyncio.create_task(self._dispatch_loop()))
|
||||||
|
|
||||||
async def run(self, filepath, quality: int, maxsize: int, maxzoom: float):
|
async def run(
|
||||||
|
self, filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
|
||||||
|
):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise PreviewError("preview worker pool closed")
|
raise PreviewError("preview worker pool closed")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
@@ -281,7 +284,7 @@ class _PreviewWorkerPool:
|
|||||||
_preview_job_priority(filepath),
|
_preview_job_priority(filepath),
|
||||||
self._seq,
|
self._seq,
|
||||||
future,
|
future,
|
||||||
(filepath, quality, maxsize, maxzoom),
|
(filepath, quality, maxsize, maxzoom, data),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return await future
|
return await future
|
||||||
@@ -355,6 +358,10 @@ class PreviewTimeoutError(Exception):
|
|||||||
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyOfficeUnavailableError(Exception):
|
||||||
|
"""Raised when the OnlyOffice Document Server is not reachable."""
|
||||||
|
|
||||||
|
|
||||||
class PreviewError(Exception):
|
class PreviewError(Exception):
|
||||||
"""Raised when the preview subprocess exits with a non-zero status."""
|
"""Raised when the preview subprocess exits with a non-zero status."""
|
||||||
|
|
||||||
@@ -370,14 +377,98 @@ class PreviewError(Exception):
|
|||||||
self.backend = backend
|
self.backend = backend
|
||||||
|
|
||||||
|
|
||||||
|
# Max concurrent OnlyOffice conversion requests. OO has its own queue;
|
||||||
|
# we must not flood it. This is intentionally small.
|
||||||
|
OO_MAX_CONCURRENT = 2
|
||||||
|
|
||||||
|
|
||||||
|
class OOConversionManager:
|
||||||
|
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
||||||
|
|
||||||
|
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
||||||
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
|
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
async def convert(self, filepath: Path) -> bytes:
|
||||||
|
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
|
||||||
|
stat = filepath.stat()
|
||||||
|
key = f"{filepath}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
|
async with self._lock:
|
||||||
|
if key in self._in_flight:
|
||||||
|
future = self._in_flight[key]
|
||||||
|
else:
|
||||||
|
future = asyncio.get_running_loop().create_future()
|
||||||
|
self._in_flight[key] = future
|
||||||
|
asyncio.create_task(self._do_convert(filepath, key, future))
|
||||||
|
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _do_convert(
|
||||||
|
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
async with self._semaphore:
|
||||||
|
oo = _get_onlyoffice()
|
||||||
|
if oo is False:
|
||||||
|
raise OnlyOfficeUnavailableError("OnlyOffice is not installed")
|
||||||
|
png_bytes = await oo.convert_to_png_async(filepath, timeout=5.0)
|
||||||
|
except Exception as e:
|
||||||
|
future.set_exception(e)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
else:
|
||||||
|
future.set_result(png_bytes)
|
||||||
|
async with self._lock:
|
||||||
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
|
|
||||||
|
_oo_manager: OOConversionManager | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_oo_manager() -> OOConversionManager:
|
||||||
|
"""Return the singleton OOConversionManager."""
|
||||||
|
global _oo_manager
|
||||||
|
if _oo_manager is None:
|
||||||
|
_oo_manager = OOConversionManager(max_concurrent=OO_MAX_CONCURRENT)
|
||||||
|
return _oo_manager
|
||||||
|
|
||||||
|
|
||||||
|
async def _generate_office_preview(
|
||||||
|
filepath: Path, quality: int, maxsize: int, maxzoom: float
|
||||||
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
|
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
|
||||||
|
oo = _get_onlyoffice()
|
||||||
|
if oo is False:
|
||||||
|
raise OnlyOfficeUnavailableError("OnlyOffice is not installed")
|
||||||
|
if not await oo.is_available_async():
|
||||||
|
raise OnlyOfficeUnavailableError("OnlyOffice Document Server is not reachable")
|
||||||
|
|
||||||
|
manager = get_oo_manager()
|
||||||
|
t_oo_start = perf_counter()
|
||||||
|
png_bytes = await manager.convert(filepath)
|
||||||
|
t_oo_end = perf_counter()
|
||||||
|
|
||||||
|
img, resp = await _run_preview_process(
|
||||||
|
filepath, quality, maxsize, maxzoom, data=png_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp is not None:
|
||||||
|
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
|
||||||
|
if resp.timings:
|
||||||
|
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
|
||||||
|
return img, resp
|
||||||
|
|
||||||
|
|
||||||
async def _run_preview_process(
|
async def _run_preview_process(
|
||||||
filepath, quality: int, maxsize: int, maxzoom: float
|
filepath, quality: int, maxsize: int, maxzoom: float, data: bytes | None = None
|
||||||
) -> tuple[bytes | None, PreviewResponse | None]:
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
"""Run preview request in a persistent worker process."""
|
"""Run preview request in a persistent worker process."""
|
||||||
await start_preview_workers()
|
await start_preview_workers()
|
||||||
if _preview_pool is None:
|
if _preview_pool is None:
|
||||||
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
raise PreviewError(f"preview worker pool unavailable for {filepath.name}")
|
||||||
return await _preview_pool.run(filepath, quality, maxsize, maxzoom)
|
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
|
||||||
|
|
||||||
|
|
||||||
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
DOC_PREVIEW_SUFFIXES = {".pdf", ".xps", ".epub", ".mobi"}
|
||||||
@@ -492,9 +583,19 @@ async def preview(req, path):
|
|||||||
|
|
||||||
# Generate preview
|
# Generate preview
|
||||||
try:
|
try:
|
||||||
|
if filepath.suffix.lower() in OFFICE_PREVIEW_SUFFIXES:
|
||||||
|
img, preview_resp = await asyncio.wait_for(
|
||||||
|
_generate_office_preview(filepath, quality, maxsize, maxzoom),
|
||||||
|
timeout=PREVIEW_TIMEOUT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
img, preview_resp = await _run_preview_process(
|
img, preview_resp = await _run_preview_process(
|
||||||
filepath, quality, maxsize, maxzoom
|
filepath, quality, maxsize, maxzoom
|
||||||
)
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return empty(504)
|
||||||
|
except OnlyOfficeUnavailableError:
|
||||||
|
return empty(503)
|
||||||
except PreviewTimeoutError:
|
except PreviewTimeoutError:
|
||||||
return empty(504)
|
return empty(504)
|
||||||
except PreviewError as e:
|
except PreviewError as e:
|
||||||
@@ -539,18 +640,16 @@ async def preview(req, path):
|
|||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
def dispatch(path, quality, maxsize, maxzoom):
|
def dispatch(path, quality, maxsize, maxzoom, data=None):
|
||||||
backend = "unknown"
|
backend = "unknown"
|
||||||
try:
|
try:
|
||||||
|
if data is not None:
|
||||||
|
backend = "pyvips"
|
||||||
|
return process_image_buffer(data, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
suffix = path.suffix.lower()
|
suffix = path.suffix.lower()
|
||||||
if suffix in DOC_PREVIEW_SUFFIXES:
|
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||||
backend = "pdf"
|
backend = "pdf"
|
||||||
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
|
||||||
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
|
||||||
backend = "onlyoffice"
|
|
||||||
return process_office(
|
|
||||||
path, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
|
||||||
)
|
|
||||||
mime_type, _ = mimetypes.guess_type(path.name)
|
mime_type, _ = mimetypes.guess_type(path.name)
|
||||||
if mime_type and mime_type.startswith("video/"):
|
if mime_type and mime_type.startswith("video/"):
|
||||||
backend = "video"
|
backend = "video"
|
||||||
@@ -592,6 +691,29 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||||
|
t_start = perf_counter()
|
||||||
|
img = pyvips.Image.new_from_buffer(data, "")
|
||||||
|
img = img.autorot()
|
||||||
|
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
img = img.resize(scale)
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif",
|
||||||
|
Q=quality,
|
||||||
|
effort=AVIF_FAST_EFFORT,
|
||||||
|
strip=True,
|
||||||
|
)
|
||||||
|
t_end = perf_counter()
|
||||||
|
|
||||||
|
return ret, PreviewResponse(
|
||||||
|
ok=True,
|
||||||
|
mime="image/avif",
|
||||||
|
backend="pyvips",
|
||||||
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
t_load_start = perf_counter()
|
t_load_start = perf_counter()
|
||||||
pdf = fitz.open(path)
|
pdf = fitz.open(path)
|
||||||
|
|||||||
+33
-6
@@ -2,9 +2,12 @@
|
|||||||
|
|
||||||
Two modes are supported:
|
Two modes are supported:
|
||||||
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
1) Legacy one-shot mode: argv has path/quality/maxsize/maxzoom.
|
||||||
2) Long-lived mode: read JSONL commands from stdin and write framed responses.
|
2) Long-lived mode: read framed requests from stdin and write framed responses.
|
||||||
|
|
||||||
Framed response format:
|
Framed request format (stdin):
|
||||||
|
(uint32 json size)(uint32 data size)(json)(binary data)
|
||||||
|
|
||||||
|
Framed response format (stdout):
|
||||||
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
(blake3(packet))(uint32 json size)(uint32 payload size)(json)(binary payload)
|
||||||
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
where packet = (uint32 json size)(uint32 payload size)(json)(binary payload).
|
||||||
"""
|
"""
|
||||||
@@ -40,6 +43,30 @@ _enc = msgspec.json.Encoder()
|
|||||||
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
_dec_req = msgspec.json.Decoder(PreviewRequest)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_exactly(f, n: int) -> bytes:
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
chunk = f.read(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
raise EOFError
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def _read_request() -> tuple[PreviewRequest, bytes] | None:
|
||||||
|
try:
|
||||||
|
header = _read_exactly(sys.stdin.buffer, 8)
|
||||||
|
except EOFError:
|
||||||
|
return None
|
||||||
|
json_size, data_size = struct.unpack("<II", header)
|
||||||
|
meta_raw = _read_exactly(sys.stdin.buffer, json_size)
|
||||||
|
data = b""
|
||||||
|
if data_size:
|
||||||
|
data = _read_exactly(sys.stdin.buffer, data_size)
|
||||||
|
req = _dec_req.decode(meta_raw)
|
||||||
|
return req, data
|
||||||
|
|
||||||
|
|
||||||
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
def _write_response(resp: PreviewResponse, payload: bytes) -> None:
|
||||||
meta_bytes = _enc.encode(resp)
|
meta_bytes = _enc.encode(resp)
|
||||||
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
packet = struct.pack("<II", len(meta_bytes), len(payload)) + meta_bytes + payload
|
||||||
@@ -70,18 +97,18 @@ def _run_loop() -> None:
|
|||||||
from cista.preview import dispatch
|
from cista.preview import dispatch
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
line = sys.stdin.buffer.readline()
|
result = _read_request()
|
||||||
if not line:
|
if result is None:
|
||||||
return
|
return
|
||||||
|
req, data = result
|
||||||
stderr_capture = io.StringIO()
|
stderr_capture = io.StringIO()
|
||||||
handler = logging.StreamHandler(stderr_capture)
|
handler = logging.StreamHandler(stderr_capture)
|
||||||
root_logger = logging.getLogger()
|
root_logger = logging.getLogger()
|
||||||
root_logger.addHandler(handler)
|
root_logger.addHandler(handler)
|
||||||
try:
|
try:
|
||||||
with contextlib.redirect_stderr(stderr_capture):
|
with contextlib.redirect_stderr(stderr_capture):
|
||||||
req = _dec_req.decode(line)
|
|
||||||
result, resp = dispatch(
|
result, resp = dispatch(
|
||||||
Path(req.path), req.quality, req.maxsize, req.maxzoom
|
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
captured = stderr_capture.getvalue().strip()
|
captured = stderr_capture.getvalue().strip()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||||
import type { Doc } from '@/repositories/Document'
|
import type { Doc } from '@/repositories/Document'
|
||||||
|
import { useMainStore } from '@/stores/main'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
const aud = ref<HTMLAudioElement | null>(null)
|
const aud = ref<HTMLAudioElement | null>(null)
|
||||||
@@ -130,7 +131,23 @@ const audio = () => ['mp3', 'flac', 'ogg', 'aac'].includes(props.doc.ext)
|
|||||||
const archive = () =>
|
const archive = () =>
|
||||||
['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
['zip', 'tar', 'gz', 'bz2', 'xz', '7z', 'rar'].includes(props.doc.ext)
|
||||||
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
const showProgress = () => !props.doc.complete && (preview() || props.doc.img)
|
||||||
const preview = () =>
|
const preview = () => {
|
||||||
|
const store = useMainStore()
|
||||||
|
const ext = props.doc.ext
|
||||||
|
// Office document previews may be optionally disabled server-side
|
||||||
|
if (store.server.office_previews === false) {
|
||||||
|
const officeExts = [
|
||||||
|
'doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'rtf',
|
||||||
|
'odt', 'ott', 'txt', 'md', 'mhtml', 'mht', 'html',
|
||||||
|
'htm', 'xml', 'wps', 'wri',
|
||||||
|
'xls', 'xlsx', 'xlsm', 'xlsb', 'xltx', 'xltm',
|
||||||
|
'ods', 'ots', 'csv',
|
||||||
|
'ppt', 'pptx', 'pptm', 'pps', 'ppsx',
|
||||||
|
'pot', 'potx', 'odp', 'otp'
|
||||||
|
]
|
||||||
|
if (officeExts.includes(ext)) return false
|
||||||
|
}
|
||||||
|
return (
|
||||||
[
|
[
|
||||||
'bmp',
|
'bmp',
|
||||||
'ico',
|
'ico',
|
||||||
@@ -180,9 +197,11 @@ const preview = () =>
|
|||||||
'potx',
|
'potx',
|
||||||
'odp',
|
'odp',
|
||||||
'otp'
|
'otp'
|
||||||
].includes(props.doc.ext) ||
|
].includes(ext) ||
|
||||||
(props.doc.size > 500000 &&
|
(props.doc.size > 500000 &&
|
||||||
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(props.doc.ext))
|
['avif', 'webp', 'png', 'jpg', 'jpeg'].includes(ext))
|
||||||
|
)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -84,6 +84,21 @@ export class Doc {
|
|||||||
// Folders cannot be previewable
|
// Folders cannot be previewable
|
||||||
if (this.dir) return false
|
if (this.dir) return false
|
||||||
if (this.img) return true
|
if (this.img) return true
|
||||||
|
const store = useMainStore()
|
||||||
|
const ext = this.ext
|
||||||
|
// Office document previews may be optionally disabled server-side
|
||||||
|
if (store.server.office_previews === false) {
|
||||||
|
const officeExts = [
|
||||||
|
'doc', 'dot', 'docx', 'docm', 'dotx', 'dotm', 'rtf',
|
||||||
|
'odt', 'ott', 'txt', 'md', 'mhtml', 'mht', 'html',
|
||||||
|
'htm', 'xml', 'wps', 'wri',
|
||||||
|
'xls', 'xlsx', 'xlsm', 'xlsb', 'xltx', 'xltm',
|
||||||
|
'ods', 'ots', 'csv',
|
||||||
|
'ppt', 'pptx', 'pptm', 'pps', 'ppsx',
|
||||||
|
'pot', 'potx', 'odp', 'otp'
|
||||||
|
]
|
||||||
|
if (officeExts.includes(ext)) return false
|
||||||
|
}
|
||||||
// Not a comprehensive list, but good enough for now
|
// Not a comprehensive list, but good enough for now
|
||||||
return [
|
return [
|
||||||
'mp4',
|
'mp4',
|
||||||
@@ -133,7 +148,7 @@ export class Doc {
|
|||||||
'potx',
|
'potx',
|
||||||
'odp',
|
'odp',
|
||||||
'otp'
|
'otp'
|
||||||
].includes(this.ext)
|
].includes(ext)
|
||||||
}
|
}
|
||||||
get previewurl(): string {
|
get previewurl(): string {
|
||||||
if (!this.complete || !this.previewable) return ''
|
if (!this.complete || !this.previewable) return ''
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ export const useMainStore = defineStore('main', {
|
|||||||
connected: false,
|
connected: false,
|
||||||
authInProgress: false,
|
authInProgress: false,
|
||||||
cursor: '' as string,
|
cursor: '' as string,
|
||||||
server: {} as Record<string, any> & { public?: boolean; paskia?: boolean },
|
server: {} as Record<string, any> & { public?: boolean; paskia?: boolean; office_previews?: boolean },
|
||||||
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
dialog: '' as '' | 'settings' | 'usermgmt' | 'accessdenied' | 'tokens',
|
||||||
uprogress: {} as any,
|
uprogress: {} as any,
|
||||||
dprogress: {} as any,
|
dprogress: {} as any,
|
||||||
|
|||||||
Reference in New Issue
Block a user