Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73c6a55bce | ||
|
|
720c4f06e5 | ||
|
|
c15e964e6d | ||
|
|
7633ee0d84 | ||
|
|
d4dfc57994 | ||
|
|
65e2fddf1a | ||
|
|
4103b8928c | ||
|
|
3c1894f337 | ||
|
|
7ac373179b | ||
|
|
a99996bd9c | ||
|
|
511cbd61b9 | ||
|
|
4acb59941b | ||
|
|
11df5cf580 | ||
|
|
c92ec3c487 |
@@ -17,12 +17,14 @@ from mediapreview.backends import (
|
|||||||
process_video,
|
process_video,
|
||||||
)
|
)
|
||||||
from mediapreview.cache import CachedPreview, PreviewCache
|
from mediapreview.cache import CachedPreview, PreviewCache
|
||||||
|
from mediapreview.exceptions import PreviewError
|
||||||
from mediapreview.formats import is_previewable_path
|
from mediapreview.formats import is_previewable_path
|
||||||
from mediapreview.protocol import PreviewRequest, PreviewResponse
|
from mediapreview.protocol import PreviewRequest, PreviewResponse
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CachedPreview",
|
"CachedPreview",
|
||||||
"PreviewCache",
|
"PreviewCache",
|
||||||
|
"PreviewError",
|
||||||
"PreviewRequest",
|
"PreviewRequest",
|
||||||
"PreviewResponse",
|
"PreviewResponse",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
|
mediapreview <path> [-o OUTPUT] [-q QUALITY] [--maxsize N] [--maxzoom Z]
|
||||||
mediapreview oosetup [<name>] [<port>]
|
mediapreview oosetup [<name>]
|
||||||
mediapreview (-h | --help)
|
mediapreview (-h | --help)
|
||||||
|
|
||||||
Generate an AVIF preview for a media file (one-shot, in-process), or set up
|
Generate an AVIF preview for a media file (one-shot, in-process), or set up
|
||||||
the bundled OnlyOffice container.
|
the bundled OnlyOffice container (isolated network, reachable from the host
|
||||||
|
at its fixed container IP).
|
||||||
|
|
||||||
Arguments:
|
Arguments:
|
||||||
<path> media file to preview
|
<path> media file to preview
|
||||||
<name> container name [default: onlyoffice-mediapreview]
|
<name> container name [default: onlyoffice-mediapreview]
|
||||||
<port> container host port [default: 8988]
|
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-o OUTPUT output .avif file (default: write AVIF bytes to stdout)
|
-o OUTPUT output .avif file (default: write AVIF bytes to stdout)
|
||||||
@@ -31,10 +31,17 @@ from pathlib import Path
|
|||||||
from docopt import docopt
|
from docopt import docopt
|
||||||
|
|
||||||
from mediapreview.backends import dispatch
|
from mediapreview.backends import dispatch
|
||||||
|
from mediapreview.exceptions import PreviewError
|
||||||
|
from mediapreview.util.logformat import EmojiFormatter
|
||||||
|
|
||||||
|
|
||||||
def _oosetup(name: str, port: int) -> None:
|
def _configure_logging() -> None:
|
||||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setFormatter(EmojiFormatter())
|
||||||
|
logging.basicConfig(level=logging.INFO, handlers=[handler])
|
||||||
|
|
||||||
|
|
||||||
|
def _oosetup(name: str) -> None:
|
||||||
try:
|
try:
|
||||||
# Lazy import: keeps the base CLI free of office-extra concerns.
|
# Lazy import: keeps the base CLI free of office-extra concerns.
|
||||||
from mediapreview.office import setup_docker # noqa: PLC0415
|
from mediapreview.office import setup_docker # noqa: PLC0415
|
||||||
@@ -43,7 +50,7 @@ def _oosetup(name: str, port: int) -> None:
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
try:
|
try:
|
||||||
# Logs go to stderr; stdout carries only the secret line below.
|
# Logs go to stderr; stdout carries only the secret line below.
|
||||||
secret = setup_docker(name=name, port=port)
|
secret = setup_docker(name=name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
|
sys.stderr.write(f"error: OnlyOffice setup failed: {e}\n")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
@@ -58,12 +65,16 @@ def _preview(args: dict) -> None:
|
|||||||
sys.stderr.write(f"error: no such file: {path}\n")
|
sys.stderr.write(f"error: no such file: {path}\n")
|
||||||
sys.exit(2)
|
sys.exit(2)
|
||||||
|
|
||||||
result, resp = dispatch(
|
try:
|
||||||
path,
|
result, resp = dispatch(
|
||||||
quality=int(args["-q"]),
|
path,
|
||||||
maxsize=int(args["--maxsize"]),
|
quality=int(args["-q"]),
|
||||||
maxzoom=float(args["--maxzoom"]),
|
maxsize=int(args["--maxsize"]),
|
||||||
)
|
maxzoom=float(args["--maxzoom"]),
|
||||||
|
)
|
||||||
|
except PreviewError as e:
|
||||||
|
sys.stderr.write(f"error: {e}\n")
|
||||||
|
sys.exit(1)
|
||||||
if not resp.ok or result is None:
|
if not resp.ok or result is None:
|
||||||
sys.stderr.write(f"error: {resp.error or 'preview failed'}\n")
|
sys.stderr.write(f"error: {resp.error or 'preview failed'}\n")
|
||||||
if resp.stderr:
|
if resp.stderr:
|
||||||
@@ -84,17 +95,13 @@ def _preview(args: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
_configure_logging()
|
||||||
# docopt matches usage patterns in order, so `oosetup` would be swallowed
|
# docopt matches usage patterns in order, so `oosetup` would be swallowed
|
||||||
# by the <path> pattern if it came second. Dispatch it before parsing;
|
# by the <path> pattern if it came second. Dispatch it before parsing;
|
||||||
# the main help above still documents both modes.
|
# the main help above still documents both modes.
|
||||||
if sys.argv[1:2] == ["oosetup"]:
|
if sys.argv[1:2] == ["oosetup"]:
|
||||||
args = docopt(
|
args = docopt("Usage:\n mediapreview oosetup [<name>]", argv=sys.argv[1:])
|
||||||
"Usage:\n mediapreview oosetup [<name>] [<port>]", argv=sys.argv[1:]
|
_oosetup(args["<name>"] or "onlyoffice-mediapreview")
|
||||||
)
|
|
||||||
_oosetup(
|
|
||||||
args["<name>"] or "onlyoffice-mediapreview",
|
|
||||||
int(args["<port>"] or 8988),
|
|
||||||
)
|
|
||||||
return
|
return
|
||||||
_preview(docopt(__doc__))
|
_preview(docopt(__doc__))
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ plus quality/size parameters and returning `(avif_bytes, PreviewResponse)`.
|
|||||||
`dispatch` picks the right backend for a path.
|
`dispatch` picks the right backend for a path.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
@@ -15,8 +16,8 @@ from mediapreview.backends.image import (
|
|||||||
)
|
)
|
||||||
from mediapreview.backends.pdf import process_pdf
|
from mediapreview.backends.pdf import process_pdf
|
||||||
from mediapreview.backends.video import process_video
|
from mediapreview.backends.video import process_video
|
||||||
from mediapreview.formats import DOC_PREVIEW_SUFFIXES
|
from mediapreview.exceptions import PreviewError, backend_error
|
||||||
from mediapreview.protocol import PreviewResponse
|
from mediapreview.formats import DOC_PREVIEW_SUFFIXES, OFFICE_PREVIEW_SUFFIXES
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -34,7 +35,7 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
|
|||||||
backend = "unknown"
|
backend = "unknown"
|
||||||
try:
|
try:
|
||||||
if data:
|
if data:
|
||||||
backend = "pyvips"
|
backend = "vips"
|
||||||
return process_image_buffer(
|
return process_image_buffer(
|
||||||
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
data, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
||||||
)
|
)
|
||||||
@@ -42,20 +43,62 @@ def dispatch(path, quality, maxsize, maxzoom, data=None):
|
|||||||
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"
|
||||||
|
try:
|
||||||
|
from mediapreview.office import ( # noqa: PLC0415
|
||||||
|
close_oo_client,
|
||||||
|
get_oo_manager,
|
||||||
|
)
|
||||||
|
except ImportError as e:
|
||||||
|
raise ImportError(
|
||||||
|
"Office document previews require the 'office' extra:"
|
||||||
|
" pip install mediapreview[office]"
|
||||||
|
) from e
|
||||||
|
try:
|
||||||
|
asyncio.get_running_loop()
|
||||||
|
except RuntimeError:
|
||||||
|
pass # no event loop, asyncio.run() is safe
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Office preview via dispatch() cannot be called inside a running"
|
||||||
|
" event loop; use mediapreview.pool.generate_office_preview() instead"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _convert_office() -> bytes:
|
||||||
|
manager = get_oo_manager()
|
||||||
|
try:
|
||||||
|
return await manager.convert(path)
|
||||||
|
finally:
|
||||||
|
await close_oo_client()
|
||||||
|
|
||||||
|
png_bytes = asyncio.run(_convert_office())
|
||||||
|
result, resp = process_image_buffer(
|
||||||
|
png_bytes, quality=quality, maxsize=maxsize, maxzoom=maxzoom
|
||||||
|
)
|
||||||
|
if resp is not None:
|
||||||
|
resp.backend = "onlyoffice+" + (resp.backend or "vips")
|
||||||
|
return result, resp
|
||||||
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"
|
||||||
return process_video(path, quality=quality, maxsize=maxsize)
|
return process_video(path, quality=quality, maxsize=maxsize)
|
||||||
if mime_type and mime_type.startswith("image/"):
|
if mime_type and mime_type.startswith("image/"):
|
||||||
backend = "pyvips"
|
backend = "vips"
|
||||||
return process_image(path, quality=quality, maxsize=maxsize)
|
return process_image(path, quality=quality, maxsize=maxsize)
|
||||||
|
except PreviewError:
|
||||||
|
# Already structured (e.g. a failing stage of a combined pipeline
|
||||||
|
# like pdf+vips) — keep the original backend identity.
|
||||||
|
raise
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
raise backend_error(backend, str(e)) from e
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
# Missing optional extra — expected, so a plain message, no traceback.
|
# Missing optional extra — expected, so a plain message, no traceback.
|
||||||
logger.error("Preview dispatch failed for %s: %s", path, e) # noqa: TRY400
|
logger.error("Preview dispatch failed for %s: %s", path, e) # noqa: TRY400
|
||||||
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
raise backend_error(backend, str(e)) from e
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Preview dispatch failed for %s", path)
|
logger.exception("Preview dispatch failed for %s", path)
|
||||||
return None, PreviewResponse(ok=False, backend=backend, error=str(e))
|
raise backend_error(backend, str(e)) from e
|
||||||
return None, PreviewResponse(ok=False, backend=backend, error="preview unsupported")
|
if not suffix:
|
||||||
|
raise backend_error(backend, "unknown file type: no file extension")
|
||||||
|
raise backend_error(backend, f"unknown file extension: {suffix!r}")
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ from time import perf_counter
|
|||||||
import pyvips
|
import pyvips
|
||||||
|
|
||||||
from mediapreview.protocol import PreviewResponse
|
from mediapreview.protocol import PreviewResponse
|
||||||
|
from mediapreview.util.logformat import quiet_vips_logging
|
||||||
|
|
||||||
|
quiet_vips_logging()
|
||||||
|
|
||||||
AVIF_FAST_EFFORT = 0
|
AVIF_FAST_EFFORT = 0
|
||||||
|
|
||||||
@@ -148,7 +151,7 @@ def process_image_pyvips(path, *, maxsize, quality):
|
|||||||
)
|
)
|
||||||
except pyvips.error.Error as e:
|
except pyvips.error.Error as e:
|
||||||
raise ValueError(f"cannot decode image: {e}") from e
|
raise ValueError(f"cannot decode image: {e}") from e
|
||||||
backend = "pyvips"
|
backend = "vips"
|
||||||
t_end = perf_counter()
|
t_end = perf_counter()
|
||||||
|
|
||||||
return ret, PreviewResponse(
|
return ret, PreviewResponse(
|
||||||
@@ -181,7 +184,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
|||||||
return ret, PreviewResponse(
|
return ret, PreviewResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend="pyvips",
|
backend="vips",
|
||||||
timings=[round((t_end - t_start) * 1000, 1)],
|
timings=[round((t_end - t_start) * 1000, 1)],
|
||||||
width=orig_w,
|
width=orig_w,
|
||||||
height=orig_h,
|
height=orig_h,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
"""PDF/XPS/EPUB preview conversion via PyMuPDF + pyvips."""
|
"""PDF/XPS/EPUB preview conversion via PyMuPDF + vips."""
|
||||||
|
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
|
||||||
import pyvips
|
import pyvips
|
||||||
|
|
||||||
from mediapreview.backends.image import AVIF_FAST_EFFORT
|
from mediapreview.backends.image import AVIF_FAST_EFFORT
|
||||||
|
from mediapreview.exceptions import backend_error
|
||||||
from mediapreview.protocol import PreviewResponse
|
from mediapreview.protocol import PreviewResponse
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -12,6 +13,8 @@ try:
|
|||||||
except ImportError: # pragma: no cover - optional pdf extra
|
except ImportError: # pragma: no cover - optional pdf extra
|
||||||
pymupdf = None
|
pymupdf = None
|
||||||
|
|
||||||
|
BACKEND = "pdf+vips"
|
||||||
|
|
||||||
|
|
||||||
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||||
if pymupdf is None:
|
if pymupdf is None:
|
||||||
@@ -19,26 +22,33 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
|||||||
"PDF previews require the 'pdf' extra: pip install mediapreview[pdf]"
|
"PDF previews require the 'pdf' extra: pip install mediapreview[pdf]"
|
||||||
)
|
)
|
||||||
t_load_start = perf_counter()
|
t_load_start = perf_counter()
|
||||||
with pymupdf.open(path) as pdf:
|
try:
|
||||||
page = pdf.load_page(page_number)
|
with pymupdf.open(path) as pdf:
|
||||||
w, h = page.rect[2:4]
|
page = pdf.load_page(page_number)
|
||||||
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
w, h = page.rect[2:4]
|
||||||
mat = pymupdf.Matrix(zoom, zoom)
|
zoom = min(maxsize / w, maxsize / h, maxzoom)
|
||||||
pix = page.get_pixmap(matrix=mat)
|
mat = pymupdf.Matrix(zoom, zoom)
|
||||||
t_load_end = perf_counter()
|
pix = page.get_pixmap(matrix=mat)
|
||||||
|
samples, width, height, n = pix.samples_mv, pix.width, pix.height, pix.n
|
||||||
|
except Exception as e:
|
||||||
|
# vips was never reached — this is a plain pdf error.
|
||||||
|
raise backend_error("pdf", str(e)) from e
|
||||||
|
t_load_end = perf_counter()
|
||||||
|
|
||||||
t_save_start = perf_counter()
|
t_save_start = perf_counter()
|
||||||
img = pyvips.Image.new_from_memory(
|
try:
|
||||||
pix.samples_mv, pix.width, pix.height, pix.n, "uchar"
|
img = pyvips.Image.new_from_memory(samples, width, height, n, "uchar")
|
||||||
|
ret = img.write_to_buffer(
|
||||||
|
".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none"
|
||||||
)
|
)
|
||||||
ret = img.write_to_buffer(".avif", Q=quality, effort=AVIF_FAST_EFFORT, keep="none")
|
except Exception as e:
|
||||||
backend = "pdf+pyvips"
|
raise backend_error(BACKEND, str(e)) from e
|
||||||
t_save_end = perf_counter()
|
t_save_end = perf_counter()
|
||||||
|
|
||||||
return ret, PreviewResponse(
|
return ret, PreviewResponse(
|
||||||
ok=True,
|
ok=True,
|
||||||
mime="image/avif",
|
mime="image/avif",
|
||||||
backend=backend,
|
backend=BACKEND,
|
||||||
timings=[
|
timings=[
|
||||||
round((t_load_end - t_load_start) * 1000, 1),
|
round((t_load_end - t_load_start) * 1000, 1),
|
||||||
round((t_save_end - t_save_start) * 1000, 1),
|
round((t_save_end - t_save_start) * 1000, 1),
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ try:
|
|||||||
import av
|
import av
|
||||||
import numpy as np
|
import numpy as np
|
||||||
except ImportError: # pragma: no cover - optional video extra
|
except ImportError: # pragma: no cover - optional video extra
|
||||||
av = None
|
av = None # type: ignore[assignment]
|
||||||
np = None
|
np = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
|
||||||
def _rotate_frame_yuv(frame, k):
|
def _rotate_frame_yuv(frame, k):
|
||||||
@@ -78,21 +78,22 @@ def process_video(path, *, maxsize, quality):
|
|||||||
istream.codec_context.skip_frame = "NONKEY"
|
istream.codec_context.skip_frame = "NONKEY"
|
||||||
icontainer.seek((icontainer.duration or 0) // 8)
|
icontainer.seek((icontainer.duration or 0) // 8)
|
||||||
for frame in icontainer.decode(istream):
|
for frame in icontainer.decode(istream):
|
||||||
if frame.dts is not None:
|
if frame.dts is not None or frame.pts is not None:
|
||||||
break
|
break
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("No frames found in video")
|
raise RuntimeError("No frames found in video")
|
||||||
|
|
||||||
# Resize frame to thumbnail size
|
# Resize frame to thumbnail size. Keep dimensions even for planar
|
||||||
|
# 4:2:0 chroma subsampling, which _rotate_frame_yuv expects.
|
||||||
# Capture display dimensions before resize (accounting for rotation)
|
# Capture display dimensions before resize (accounting for rotation)
|
||||||
disp_w = frame.width
|
disp_w = frame.width
|
||||||
disp_h = frame.height
|
disp_h = frame.height
|
||||||
if frame.rotation in (90, 270):
|
if abs(frame.rotation) in (90, 270):
|
||||||
disp_w, disp_h = disp_h, disp_w
|
disp_w, disp_h = disp_h, disp_w
|
||||||
if frame.width > maxsize or frame.height > maxsize:
|
if frame.width > maxsize or frame.height > maxsize:
|
||||||
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||||
new_width = int(frame.width * scale_factor)
|
new_width = int(frame.width * scale_factor) // 2 * 2
|
||||||
new_height = int(frame.height * scale_factor)
|
new_height = int(frame.height * scale_factor) // 4 * 4
|
||||||
frame = frame.reformat(width=new_width, height=new_height)
|
frame = frame.reformat(width=new_width, height=new_height)
|
||||||
|
|
||||||
# Apply display-matrix rotation if present
|
# Apply display-matrix rotation if present
|
||||||
|
|||||||
@@ -36,10 +36,17 @@ RUN apt-get update -qq && \
|
|||||||
ca-certificates && \
|
ca-certificates && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Clone the open-source server components (shallow, ~15 MB).
|
# Pin the open-source server components to a known-good commit (~15 MB).
|
||||||
# The master branch is used because the Linux/web tags are not published
|
# The master branch is a moving target (the Linux/web tags are not published
|
||||||
# in the server repo; the license.js file has been stable for years.
|
# in the server repo): a 2026 convertermaster change there detects community
|
||||||
RUN git clone --depth 1 https://github.com/ONLYOFFICE/server.git /opt/oo-server
|
# edition as a "memory runtime" and forks NO converter workers, silently
|
||||||
|
# breaking all conversions. Pinned to the commit proven in production.
|
||||||
|
ARG ONLYOFFICE_SERVER_REF=4e56b8d056640557ffcd8c860a65535ab6cbd95b
|
||||||
|
RUN git init /opt/oo-server && \
|
||||||
|
cd /opt/oo-server && \
|
||||||
|
git remote add origin https://github.com/ONLYOFFICE/server.git && \
|
||||||
|
git fetch --depth 1 origin "$ONLYOFFICE_SERVER_REF" && \
|
||||||
|
git checkout FETCH_HEAD
|
||||||
|
|
||||||
# Patch license.js so the converter worker count is read from an env var
|
# Patch license.js so the converter worker count is read from an env var
|
||||||
# instead of being hardcoded to 1.
|
# instead of being hardcoded to 1.
|
||||||
@@ -47,6 +54,12 @@ RUN sed -i \
|
|||||||
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
's/count: 1,/count: parseInt(process.env.WORKERS, 10) || 8,/' \
|
||||||
/opt/oo-server/Common/sources/license.js
|
/opt/oo-server/Common/sources/license.js
|
||||||
|
|
||||||
|
# Defense in depth: never take the "memory runtime" branch that forks no
|
||||||
|
# converter workers, even if the pinned ref is bumped carelessly.
|
||||||
|
RUN sed -i \
|
||||||
|
's/runtimeProfile\.isMemoryRuntime()/false \/* patched: always fork converter workers *\//g' \
|
||||||
|
/opt/oo-server/FileConverter/sources/convertermaster.js
|
||||||
|
|
||||||
# Install npm dependencies for the modules the FileConverter touches.
|
# Install npm dependencies for the modules the FileConverter touches.
|
||||||
# DocService deps are also needed because converter.js pulls in baseConnector.
|
# DocService deps are also needed because converter.js pulls in baseConnector.
|
||||||
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
RUN cd /opt/oo-server/Common && npm ci --no-audit --no-fund
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
"""Structured preview exceptions.
|
||||||
|
|
||||||
|
Preview failures are represented by ``PreviewError`` and a small number of
|
||||||
|
subclasses, carrying their fields directly: ``short`` (concise, for UI with
|
||||||
|
limited space — the backend name is usually printed in front of it, so it is
|
||||||
|
left out), the exception message itself (for logs), ``backend``, and
|
||||||
|
optional subclass-specific metadata such as ``code`` or ``timeout_seconds``
|
||||||
|
for callers that wish to do their own processing.
|
||||||
|
|
||||||
|
The worker pool ships exceptions between processes with pickle (same
|
||||||
|
trust domain — the pool unpickles only data from its own workers), so any
|
||||||
|
exception arrives intact on the caller side, no per-class serialization
|
||||||
|
machinery needed. Because the initializers are keyword-heavy, pickling is
|
||||||
|
routed through ``__dict__`` via ``PreviewError.__reduce__``.
|
||||||
|
|
||||||
|
The hierarchy is intentionally small:
|
||||||
|
|
||||||
|
- ``OnlyOfficeError`` covers all OnlyOffice failures; optional fields
|
||||||
|
(``code``, ``status``, ``url``, ``snippet``) describe the specific failure.
|
||||||
|
- ``PreviewBackendError`` covers backend conversion failures (ffmpeg, vips,
|
||||||
|
pdf, etc.). Combined pipelines report the failing step in ``backend``
|
||||||
|
(e.g. "pdf" if pdf reading failed before vips was reached, "pdf+vips"
|
||||||
|
for a vips write failure).
|
||||||
|
- ``PreviewTimeoutError`` covers timeouts for any backend.
|
||||||
|
- ``PreviewCancelledError`` covers cancellations (e.g. pool shutdown).
|
||||||
|
|
||||||
|
BaseExceptions such as ``KeyboardInterrupt``, ``SystemExit`` and
|
||||||
|
``asyncio.CancelledError`` are never wrapped in these types.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewError(Exception):
|
||||||
|
"""Base preview exception.
|
||||||
|
|
||||||
|
``short`` is concise text for UIs with limited space (the backend name
|
||||||
|
is usually printed in front of it); ``str(err)`` is the full
|
||||||
|
human-readable message; ``backend`` identifies the backend
|
||||||
|
(e.g. "onlyoffice", "ffmpeg").
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "preview failed",
|
||||||
|
short: str = "error",
|
||||||
|
*,
|
||||||
|
backend: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.short = short
|
||||||
|
self.backend = backend
|
||||||
|
|
||||||
|
def __reduce__(self):
|
||||||
|
# Keyword-heavy initializers do not unpickle via args; pass the message
|
||||||
|
# positionally and restore the rest from __dict__.
|
||||||
|
return (type(self), (str(self),), self.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
class OnlyOfficeError(PreviewError):
|
||||||
|
"""OnlyOffice conversion failed. Specifics are in the extra fields."""
|
||||||
|
|
||||||
|
def __init__( # noqa: PLR0913 - metadata fields are independent
|
||||||
|
self,
|
||||||
|
message: str = "OnlyOffice conversion failed",
|
||||||
|
short: str = "error",
|
||||||
|
*,
|
||||||
|
code: str | None = None,
|
||||||
|
status: int | None = None,
|
||||||
|
url: str | None = None,
|
||||||
|
snippet: str | None = None,
|
||||||
|
backend: str | None = "onlyoffice",
|
||||||
|
):
|
||||||
|
super().__init__(message, short, backend=backend)
|
||||||
|
self.code = code
|
||||||
|
self.status = status
|
||||||
|
self.url = url
|
||||||
|
self.snippet = snippet
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewBackendError(PreviewError):
|
||||||
|
"""Backend conversion failure (image/video/pdf/etc)."""
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewTimeoutError(PreviewError):
|
||||||
|
"""Preview conversion exceeded its timeout for a given backend."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "preview timed out",
|
||||||
|
short: str = "timeout",
|
||||||
|
*,
|
||||||
|
timeout_seconds: float = 0.0,
|
||||||
|
backend: str | None = None,
|
||||||
|
fetched: bool | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message, short, backend=backend)
|
||||||
|
self.timeout_seconds = timeout_seconds
|
||||||
|
self.fetched = fetched
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewCancelledError(PreviewError):
|
||||||
|
"""Preview was cancelled (e.g. pool shut down)."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str = "Preview cancelled (pool closed)",
|
||||||
|
short: str = "cancelled",
|
||||||
|
*,
|
||||||
|
reason: str = "pool closed",
|
||||||
|
backend: str | None = None,
|
||||||
|
):
|
||||||
|
super().__init__(message, short, backend=backend)
|
||||||
|
self.reason = reason
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Factory helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_OO_CODE_ERRORS = {
|
||||||
|
"-8": ("jwt error", "OnlyOffice JWT authentication failed"),
|
||||||
|
"-4": ("input error", "OnlyOffice input error"),
|
||||||
|
"-2": ("timeout error", "OnlyOffice conversion timed out"),
|
||||||
|
"-1": ("unknown error", "OnlyOffice conversion failed with unknown error"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_error_from_code(code: str | None = None) -> OnlyOfficeError:
|
||||||
|
"""Build an OnlyOfficeError from a conversion status code (e.g. "-8")."""
|
||||||
|
if code in _OO_CODE_ERRORS:
|
||||||
|
short, log = _OO_CODE_ERRORS[code]
|
||||||
|
elif code:
|
||||||
|
short, log = f"{code} error", f"OnlyOffice conversion failed: {code}"
|
||||||
|
else:
|
||||||
|
short, log = "unknown error", "OnlyOffice conversion failed with unknown error"
|
||||||
|
return OnlyOfficeError(log, short, code=code)
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_unavailable_error(url: str | None = None) -> OnlyOfficeError:
|
||||||
|
log = "OnlyOffice document server not reachable"
|
||||||
|
if url:
|
||||||
|
log = f"{log} at {url}"
|
||||||
|
return OnlyOfficeError(log, "unavailable", url=url)
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_http_error(status: int) -> OnlyOfficeError:
|
||||||
|
return OnlyOfficeError(
|
||||||
|
f"OnlyOffice HTTP error: {status}", "http error", status=status
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def onlyoffice_no_fileurl_error(snippet: str | None = None) -> OnlyOfficeError:
|
||||||
|
log = "OnlyOffice response did not contain FileUrl"
|
||||||
|
if snippet:
|
||||||
|
log = f"{log}: {snippet}"
|
||||||
|
return OnlyOfficeError(log, "no-fileurl error", snippet=snippet)
|
||||||
|
|
||||||
|
|
||||||
|
def backend_error(backend: str, message: str) -> PreviewBackendError:
|
||||||
|
short = message.splitlines()[0]
|
||||||
|
# Many backend messages look like "source: summary: detail ...".
|
||||||
|
# Drop the source prefix and any trailing detail so the short label
|
||||||
|
# is usable in UIs with limited space.
|
||||||
|
if ": " in short:
|
||||||
|
short = short.split(": ", 1)[1]
|
||||||
|
if ": " in short:
|
||||||
|
short = short.split(": ", 1)[0]
|
||||||
|
short = short[:60]
|
||||||
|
return PreviewBackendError(
|
||||||
|
f"[{backend}] preview failed: {message}",
|
||||||
|
short,
|
||||||
|
backend=backend,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_timeout_error(
|
||||||
|
backend: str, timeout_seconds: float, fetched: bool | None = None
|
||||||
|
) -> PreviewTimeoutError:
|
||||||
|
log = f"{backend.capitalize()} preview timed out after {timeout_seconds}s"
|
||||||
|
if fetched is not None:
|
||||||
|
# OnlyOffice: whether it ever downloaded the input file from our
|
||||||
|
# callback server distinguishes network/callback failures from a
|
||||||
|
# stalled conversion.
|
||||||
|
log += " (input file fetched)" if fetched else " (input file never fetched)"
|
||||||
|
return PreviewTimeoutError(
|
||||||
|
log,
|
||||||
|
"timeout",
|
||||||
|
backend=backend,
|
||||||
|
timeout_seconds=timeout_seconds,
|
||||||
|
fetched=fetched,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def preview_cancelled_error(reason: str = "pool closed") -> PreviewCancelledError:
|
||||||
|
return PreviewCancelledError(
|
||||||
|
f"Preview cancelled ({reason})", "cancelled", reason=reason
|
||||||
|
)
|
||||||
@@ -90,5 +90,5 @@ def expected_backend(path: Path) -> str:
|
|||||||
if mime_type and mime_type.startswith("video/"):
|
if mime_type and mime_type.startswith("video/"):
|
||||||
return "video"
|
return "video"
|
||||||
if mime_type and mime_type.startswith("image/"):
|
if mime_type and mime_type.startswith("image/"):
|
||||||
return "pyvips"
|
return "vips"
|
||||||
return "preview"
|
return "preview"
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ from pathlib import Path
|
|||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from mediapreview.exceptions import (
|
||||||
|
onlyoffice_error_from_code,
|
||||||
|
onlyoffice_http_error,
|
||||||
|
onlyoffice_no_fileurl_error,
|
||||||
|
onlyoffice_unavailable_error,
|
||||||
|
preview_timeout_error,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import httpx
|
import httpx
|
||||||
import jwt
|
import jwt
|
||||||
@@ -39,18 +47,31 @@ except ImportError: # pragma: no cover - optional office extra
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Isolated docker network for the OnlyOffice container: internal-only (no
|
||||||
|
# outbound internet), the container can only reach the host on this bridge.
|
||||||
|
# Docker discards published ports on internal networks, so the container is
|
||||||
|
# reached at its fixed IP instead of a published localhost port. The host is
|
||||||
|
# always the first address of the pinned subnet (the bridge gateway).
|
||||||
|
OO_NETWORK = "oonet"
|
||||||
|
OO_SUBNET = "172.30.0.0/24"
|
||||||
|
OO_GATEWAY = "172.30.0.1"
|
||||||
|
OO_CONTAINER_IP = "172.30.0.2"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Configuration helpers
|
# Configuration helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
_httpx_client: httpx.AsyncClient | None = None
|
_httpx_client: httpx.AsyncClient | None = None
|
||||||
|
_httpx_client_loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
|
||||||
|
|
||||||
def _get_onlyoffice_url() -> str:
|
def _get_onlyoffice_url() -> str:
|
||||||
|
# The container runs on the isolated oonet network at a fixed IP; the
|
||||||
|
# host is the bridge gateway and reaches it directly, no published port.
|
||||||
return os.environ.get(
|
return os.environ.get(
|
||||||
"ONLYOFFICE_URL",
|
"ONLYOFFICE_URL",
|
||||||
os.environ.get("ONLYOFFICE_CISTA_URL", "http://localhost:8988"),
|
os.environ.get("ONLYOFFICE_CISTA_URL", f"http://{OO_CONTAINER_IP}"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -60,57 +81,37 @@ def _get_jwt_secret() -> str:
|
|||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
def _get_callback_host() -> str:
|
def _get_callback_host() -> str:
|
||||||
"""Return the host IP that OnlyOffice (usually in Docker) can use to reach us."""
|
"""Return the host IP that OnlyOffice (in Docker) can use to reach us.
|
||||||
|
|
||||||
|
The host is always the gateway of the pinned oonet subnet; no detection
|
||||||
|
is needed (the cista service account may not have docker CLI access).
|
||||||
|
"""
|
||||||
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
if host := os.environ.get("ONLYOFFICE_CALLBACK_HOST"):
|
||||||
return host
|
return host
|
||||||
# Try to auto-detect docker bridge IP
|
return OO_GATEWAY
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["/sbin/ip", "-4", "addr", "show", "docker0"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=2,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
if "inet " in line:
|
|
||||||
parts = line.strip().split()
|
|
||||||
addr_part = parts[1] # e.g. 172.17.0.1/16
|
|
||||||
return addr_part.split("/")[0]
|
|
||||||
except Exception:
|
|
||||||
logger.debug("Failed to auto-detect docker bridge IP")
|
|
||||||
return "127.0.0.1"
|
|
||||||
|
|
||||||
|
|
||||||
def onlyoffice_error_short_text(detail: str) -> str:
|
|
||||||
"""Short human-readable label for an OnlyOffice failure, for log annotation."""
|
|
||||||
if detail.startswith("OnlyOffice conversion error:"):
|
|
||||||
code = detail.rsplit(":", 1)[-1].strip()
|
|
||||||
return {
|
|
||||||
"-8": "onlyoffice jwt error",
|
|
||||||
"-4": "onlyoffice input error",
|
|
||||||
"-2": "onlyoffice timeout error",
|
|
||||||
"-1": "onlyoffice unknown error",
|
|
||||||
}.get(code, f"onlyoffice {code} error")
|
|
||||||
if "OnlyOffice response did not contain FileUrl" in detail:
|
|
||||||
return "onlyoffice no-fileurl error"
|
|
||||||
return "onlyoffice error"
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Async HTTP client
|
# Async HTTP client
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def get_httpx_client() -> httpx.AsyncClient:
|
def get_httpx_client() -> httpx.AsyncClient:
|
||||||
"""Return the shared async HTTP client for OnlyOffice requests."""
|
"""Return the shared async HTTP client for OnlyOffice requests.
|
||||||
|
|
||||||
|
The client is recreated if the running event loop changes, because an
|
||||||
|
``httpx.AsyncClient`` is bound to the loop that created it.
|
||||||
|
"""
|
||||||
if httpx is None:
|
if httpx is None:
|
||||||
raise ImportError(
|
raise ImportError(
|
||||||
"OnlyOffice integration requires the 'office' extra: pip install mediapreview[office]"
|
"OnlyOffice integration requires the 'office' extra: pip install mediapreview[office]"
|
||||||
)
|
)
|
||||||
global _httpx_client
|
global _httpx_client, _httpx_client_loop
|
||||||
if _httpx_client is None:
|
current_loop = asyncio.get_running_loop()
|
||||||
|
if _httpx_client is None or _httpx_client_loop is not current_loop:
|
||||||
_httpx_client = httpx.AsyncClient()
|
_httpx_client = httpx.AsyncClient()
|
||||||
|
_httpx_client_loop = current_loop
|
||||||
return _httpx_client
|
return _httpx_client
|
||||||
|
|
||||||
|
|
||||||
@@ -155,12 +156,14 @@ def log_reachable_info() -> None:
|
|||||||
logger.warning("OnlyOffice probe failed%s", suffix)
|
logger.warning("OnlyOffice probe failed%s", suffix)
|
||||||
|
|
||||||
|
|
||||||
def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str:
|
def setup_docker(name: str = "onlyoffice-mediapreview") -> str:
|
||||||
"""Build and run the patched OnlyOffice Docker image.
|
"""Build and run the patched OnlyOffice Docker image.
|
||||||
|
|
||||||
Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a random secret.
|
The container runs on an isolated internal network (OO_NETWORK) with no
|
||||||
Returns the secret used, so the caller is responsible for persisting it
|
outbound internet and no published ports; the host reaches it at
|
||||||
(the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
|
OO_CONTAINER_IP. Uses ONLYOFFICE_JWT_SECRET if set, otherwise generates a
|
||||||
|
random secret. Returns the secret used, so the caller is responsible for
|
||||||
|
persisting it (the CLI prints it as `ONLYOFFICE_JWT_SECRET=<token>`).
|
||||||
The Docker build context ships inside the package at `mediapreview/docker`.
|
The Docker build context ships inside the package at `mediapreview/docker`.
|
||||||
"""
|
"""
|
||||||
if secret := _get_jwt_secret():
|
if secret := _get_jwt_secret():
|
||||||
@@ -181,13 +184,33 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError("Failed to build OnlyOffice image")
|
raise RuntimeError("Failed to build OnlyOffice image")
|
||||||
|
|
||||||
|
# Isolated network: internal-only, so the container has no outbound
|
||||||
|
# internet access and can only reach the host on this bridge (needed
|
||||||
|
# for the preview file callback). Already-exists is fine.
|
||||||
|
net_cmd = [
|
||||||
|
"docker",
|
||||||
|
"network",
|
||||||
|
"create",
|
||||||
|
"--internal",
|
||||||
|
"--subnet",
|
||||||
|
OO_SUBNET,
|
||||||
|
OO_NETWORK,
|
||||||
|
]
|
||||||
|
result = subprocess.run(net_cmd, capture_output=True, check=False) # noqa: S603
|
||||||
|
if result.returncode != 0 and b"already exists" not in result.stderr:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to create docker network {OO_NETWORK}: {result.stderr.decode(errors='replace').strip()}"
|
||||||
|
)
|
||||||
|
|
||||||
logger.info("Starting OnlyOffice container")
|
logger.info("Starting OnlyOffice container")
|
||||||
run_cmd = [
|
run_cmd = [
|
||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
"-d",
|
"-d",
|
||||||
"-p",
|
"--network",
|
||||||
f"{port}:80",
|
OO_NETWORK,
|
||||||
|
"--ip",
|
||||||
|
OO_CONTAINER_IP,
|
||||||
"-e",
|
"-e",
|
||||||
f"JWT_SECRET={secret}",
|
f"JWT_SECRET={secret}",
|
||||||
"-e",
|
"-e",
|
||||||
@@ -202,7 +225,10 @@ def setup_docker(name: str = "onlyoffice-mediapreview", port: int = 8988) -> str
|
|||||||
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
result = subprocess.run(run_cmd, check=False, shell=False) # noqa: S603
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError("Failed to start OnlyOffice container")
|
raise RuntimeError("Failed to start OnlyOffice container")
|
||||||
logger.info("OnlyOffice is running on http://localhost:%d", port)
|
# Docker discards published ports on internal networks, so the container
|
||||||
|
# is reached at its fixed IP; no localhost port is exposed.
|
||||||
|
logger.info("OnlyOffice is running on http://%s", OO_CONTAINER_IP)
|
||||||
|
logger.info("Callback host for file downloads: %s", OO_GATEWAY)
|
||||||
return secret
|
return secret
|
||||||
|
|
||||||
|
|
||||||
@@ -257,29 +283,65 @@ async def is_available_cached() -> bool:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _TempServer(socketserver.TCPServer):
|
||||||
|
"""TCPServer that logs handler errors instead of dumping tracebacks to stderr."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
oo_fetched: bool
|
||||||
|
|
||||||
|
def handle_error(self, request, client_address) -> None: # noqa: ARG002
|
||||||
|
# Dropped connections (client disconnects mid-request, port scanners)
|
||||||
|
# are routine noise; socketserver's default prints a full traceback.
|
||||||
|
logger.debug("Temp file server: error from %s", client_address)
|
||||||
|
|
||||||
|
|
||||||
class _QuietHandler(SimpleHTTPRequestHandler):
|
class _QuietHandler(SimpleHTTPRequestHandler):
|
||||||
|
server: _TempServer
|
||||||
|
|
||||||
def log_message(self, fmt, *args) -> None:
|
def log_message(self, fmt, *args) -> None:
|
||||||
pass
|
# Any request logged here means a client (OnlyOffice) connected to
|
||||||
|
# fetch the file; record it for timeout diagnostics.
|
||||||
|
self.server.oo_fetched = True
|
||||||
|
|
||||||
|
|
||||||
def _get_free_port() -> int:
|
def _get_free_port(host: str) -> int:
|
||||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
s.bind(("0.0.0.0", 0)) # noqa: S104
|
s.bind((host, 0))
|
||||||
return s.getsockname()[1]
|
return s.getsockname()[1]
|
||||||
|
|
||||||
|
|
||||||
def _serve_file_temporarily(file_path: Path):
|
def _serve_file_temporarily(file_path: Path, max_lifetime: float = 60.0):
|
||||||
"""Start a temporary HTTP server for *file_path* and return (url, server)."""
|
"""Start a temporary HTTP server for *file_path* and return (url, server).
|
||||||
|
|
||||||
|
The server binds only to the callback host address (the docker bridge
|
||||||
|
gateway by default), not 0.0.0.0, so it is unreachable from the internet.
|
||||||
|
It shuts itself down shortly after the file has been fetched, or when
|
||||||
|
*max_lifetime* elapses, so a hung OnlyOffice request cannot leave the
|
||||||
|
port open indefinitely.
|
||||||
|
"""
|
||||||
directory = str(file_path.parent)
|
directory = str(file_path.parent)
|
||||||
filename = file_path.name
|
filename = file_path.name
|
||||||
port = _get_free_port()
|
host = _get_callback_host()
|
||||||
|
port = _get_free_port(host)
|
||||||
|
|
||||||
handler = partial(_QuietHandler, directory=directory)
|
handler = partial(_QuietHandler, directory=directory)
|
||||||
httpd = socketserver.TCPServer(("0.0.0.0", port), handler) # noqa: S104
|
httpd = _TempServer((host, port), handler)
|
||||||
|
httpd.oo_fetched = False
|
||||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
host = _get_callback_host()
|
def _watchdog() -> None:
|
||||||
|
deadline = perf_counter() + max_lifetime
|
||||||
|
while perf_counter() < deadline and not httpd.oo_fetched:
|
||||||
|
threading.Event().wait(0.1)
|
||||||
|
if httpd.oo_fetched:
|
||||||
|
# Brief grace so the in-flight response finishes transferring.
|
||||||
|
threading.Event().wait(2.0)
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
threading.Thread(target=_watchdog, daemon=True).start()
|
||||||
|
|
||||||
url = f"http://{host}:{port}/{quote(filename)}"
|
url = f"http://{host}:{port}/{quote(filename)}"
|
||||||
return url, httpd
|
return url, httpd
|
||||||
|
|
||||||
@@ -296,9 +358,14 @@ def _build_jwt_token(payload: dict) -> str | None:
|
|||||||
return jwt.encode(payload, secret, algorithm="HS256")
|
return jwt.encode(payload, secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) -> bytes:
|
async def convert_to_png_async(
|
||||||
|
file_path: Path, request_timeout: float = 7.0, download_timeout: float = 2.0
|
||||||
|
) -> bytes:
|
||||||
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
"""Convert *file_path* to PNG using OnlyOffice Document Server (async).
|
||||||
|
|
||||||
|
With ``async: false`` the conversion itself runs inside the POST request,
|
||||||
|
so *request_timeout* must cover full conversion time. *download_timeout*
|
||||||
|
covers fetching the resulting one-page PNG, which is pure transfer.
|
||||||
Returns the PNG bytes. Raises RuntimeError on failure.
|
Returns the PNG bytes. Raises RuntimeError on failure.
|
||||||
"""
|
"""
|
||||||
if httpx is None or jwt is None:
|
if httpx is None or jwt is None:
|
||||||
@@ -309,8 +376,12 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
convert_url = f"{oo_url}/ConvertService.ashx"
|
convert_url = f"{oo_url}/ConvertService.ashx"
|
||||||
client = get_httpx_client()
|
client = get_httpx_client()
|
||||||
|
|
||||||
# Start temporary HTTP server so OnlyOffice can fetch the file
|
# Start temporary HTTP server so OnlyOffice can fetch the file. The
|
||||||
doc_url, httpd = await asyncio.to_thread(_serve_file_temporarily, file_path)
|
# watchdog lifetime covers the full conversion plus slack so a hung
|
||||||
|
# conversion cannot leave the port open forever.
|
||||||
|
doc_url, httpd = await asyncio.to_thread(
|
||||||
|
_serve_file_temporarily, file_path, request_timeout + 30.0
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
suffix = file_path.suffix.lstrip(".").lower()
|
suffix = file_path.suffix.lstrip(".").lower()
|
||||||
payload = {
|
payload = {
|
||||||
@@ -330,26 +401,36 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
headers["Authorization"] = token
|
headers["Authorization"] = token
|
||||||
|
|
||||||
t_start = perf_counter()
|
t_start = perf_counter()
|
||||||
response = await client.post(
|
try:
|
||||||
convert_url,
|
response = await client.post(
|
||||||
content=json.dumps(payload).encode(),
|
convert_url,
|
||||||
headers=headers,
|
content=json.dumps(payload).encode(),
|
||||||
timeout=request_timeout,
|
headers=headers,
|
||||||
)
|
timeout=request_timeout,
|
||||||
response.raise_for_status()
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
raise preview_timeout_error(
|
||||||
|
"onlyoffice", request_timeout, fetched=httpd.oo_fetched
|
||||||
|
) from e
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise onlyoffice_http_error(e.response.status_code) from e
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise onlyoffice_unavailable_error(_get_onlyoffice_url()) from e
|
||||||
body = response.content
|
body = response.content
|
||||||
t_end = perf_counter()
|
t_end = perf_counter()
|
||||||
|
|
||||||
# Parse XML response
|
# Parse XML response
|
||||||
text = body.decode("utf-8", errors="replace")
|
text = body.decode("utf-8", errors="replace")
|
||||||
if "<Error>" in text:
|
if "<Error>" in text:
|
||||||
code = "unknown"
|
code = None
|
||||||
if "<Error>" in text and "</Error>" in text:
|
if "</Error>" in text:
|
||||||
code = text.split("<Error>")[1].split("</Error>")[0]
|
code = text.split("<Error>")[1].split("</Error>")[0]
|
||||||
raise RuntimeError(f"OnlyOffice conversion error: {code}")
|
raise onlyoffice_error_from_code(code)
|
||||||
|
|
||||||
if "<FileUrl>" not in text:
|
if "<FileUrl>" not in text:
|
||||||
raise RuntimeError("OnlyOffice response did not contain FileUrl")
|
snippet = text if len(text) <= 200 else text[:200] + "..."
|
||||||
|
raise onlyoffice_no_fileurl_error(snippet)
|
||||||
|
|
||||||
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
|
file_url = text.split("<FileUrl>")[1].split("</FileUrl>")[0]
|
||||||
file_url = file_url.replace("&", "&")
|
file_url = file_url.replace("&", "&")
|
||||||
@@ -357,11 +438,21 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
logger.debug("OnlyOffice converted in %.2fs: %s", t_end - t_start, file_url)
|
||||||
|
|
||||||
# Download converted PNG
|
# Download converted PNG
|
||||||
png_response = await client.get(file_url, timeout=request_timeout)
|
try:
|
||||||
png_response.raise_for_status()
|
png_response = await client.get(file_url, timeout=download_timeout)
|
||||||
|
png_response.raise_for_status()
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
raise preview_timeout_error("onlyoffice", download_timeout) from e
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise onlyoffice_http_error(e.response.status_code) from e
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
# The converted file lives on the OO server, so a request failure here
|
||||||
|
# usually means OO itself could not be reached after conversion.
|
||||||
|
raise onlyoffice_unavailable_error(_get_onlyoffice_url()) from e
|
||||||
return png_response.content
|
return png_response.content
|
||||||
finally:
|
finally:
|
||||||
await asyncio.to_thread(httpd.shutdown)
|
await asyncio.to_thread(httpd.shutdown)
|
||||||
|
await asyncio.to_thread(httpd.server_close)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -373,48 +464,78 @@ async def convert_to_png_async(file_path: Path, request_timeout: float = 5.0) ->
|
|||||||
OO_MAX_CONCURRENT = max(2, min(8, cpu_count()))
|
OO_MAX_CONCURRENT = max(2, min(8, cpu_count()))
|
||||||
|
|
||||||
|
|
||||||
|
class _InFlight:
|
||||||
|
"""A deduplicated conversion: shared future, its task, and waiter count."""
|
||||||
|
|
||||||
|
__slots__ = ("future", "task", "waiters")
|
||||||
|
|
||||||
|
def __init__(self, future: asyncio.Future[bytes], task: asyncio.Task[None]):
|
||||||
|
self.future = future
|
||||||
|
self.task = task
|
||||||
|
self.waiters = 0
|
||||||
|
|
||||||
|
|
||||||
class OOConversionManager:
|
class OOConversionManager:
|
||||||
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
"""Manages async OnlyOffice conversions with deduplication and concurrency limits."""
|
||||||
|
|
||||||
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
def __init__(self, max_concurrent: int = OO_MAX_CONCURRENT):
|
||||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||||
self._in_flight: dict[str, asyncio.Future[bytes]] = {}
|
self._in_flight: dict[str, _InFlight] = {}
|
||||||
self._tasks: set[asyncio.Task[None]] = set()
|
self._tasks: set[asyncio.Task[None]] = set()
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
async def convert(self, filepath: Path) -> bytes:
|
async def convert(self, filepath: Path) -> bytes:
|
||||||
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
|
"""Return PNG bytes for *filepath*, deduplicating concurrent requests."""
|
||||||
if not await is_available_cached():
|
if not await is_available_cached():
|
||||||
raise RuntimeError("OnlyOffice server not reachable")
|
raise onlyoffice_unavailable_error(_get_onlyoffice_url())
|
||||||
stat = await asyncio.to_thread(filepath.stat)
|
stat = await asyncio.to_thread(filepath.stat)
|
||||||
key = f"{filepath}:{stat.st_mtime_ns}"
|
key = f"{filepath}:{stat.st_mtime_ns}"
|
||||||
|
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
if key in self._in_flight:
|
entry = self._in_flight.get(key)
|
||||||
future = self._in_flight[key]
|
if entry is None:
|
||||||
else:
|
|
||||||
future = asyncio.get_running_loop().create_future()
|
future = asyncio.get_running_loop().create_future()
|
||||||
self._in_flight[key] = future
|
|
||||||
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
task = asyncio.create_task(self._do_convert(filepath, key, future))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(self._tasks.discard)
|
task.add_done_callback(self._tasks.discard)
|
||||||
|
entry = _InFlight(future, task)
|
||||||
|
self._in_flight[key] = entry
|
||||||
|
entry.waiters += 1
|
||||||
|
|
||||||
return await future
|
try:
|
||||||
|
# shield: one waiter's cancellation must not cancel the future
|
||||||
|
# shared with other waiters.
|
||||||
|
return await asyncio.shield(entry.future)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# The caller hit the (strict) preview deadline or disconnected.
|
||||||
|
# When no other waiter remains, cancel the background task so it
|
||||||
|
# releases its semaphore slot and aborts the HTTP request instead
|
||||||
|
# of running orphaned and piling load onto OnlyOffice.
|
||||||
|
async with self._lock:
|
||||||
|
entry.waiters -= 1
|
||||||
|
orphan = entry.waiters == 0
|
||||||
|
if orphan:
|
||||||
|
entry.task.cancel()
|
||||||
|
raise
|
||||||
|
|
||||||
async def _do_convert(
|
async def _do_convert(
|
||||||
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
self, filepath: Path, key: str, future: asyncio.Future[bytes]
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
async with self._semaphore:
|
async with self._semaphore:
|
||||||
png_bytes = await convert_to_png_async(filepath, request_timeout=5.0)
|
png_bytes = await convert_to_png_async(filepath)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# All waiters gave up; cancel the future so nothing hangs on it.
|
||||||
|
if not future.done():
|
||||||
|
future.cancel()
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(e)
|
future.set_exception(e)
|
||||||
async with self._lock:
|
|
||||||
self._in_flight.pop(key, None)
|
|
||||||
else:
|
else:
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_result(png_bytes)
|
future.set_result(png_bytes)
|
||||||
|
finally:
|
||||||
async with self._lock:
|
async with self._lock:
|
||||||
self._in_flight.pop(key, None)
|
self._in_flight.pop(key, None)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import asyncio
|
|||||||
import contextlib
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import pickle
|
||||||
import signal
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -20,6 +21,13 @@ except ImportError as e: # pragma: no cover - optional worker extra
|
|||||||
"The worker pool requires the 'worker' extra: pip install mediapreview[worker]"
|
"The worker pool requires the 'worker' extra: pip install mediapreview[worker]"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
from mediapreview.exceptions import (
|
||||||
|
PreviewError,
|
||||||
|
PreviewTimeoutError,
|
||||||
|
backend_error,
|
||||||
|
preview_cancelled_error,
|
||||||
|
preview_timeout_error,
|
||||||
|
)
|
||||||
from mediapreview.formats import (
|
from mediapreview.formats import (
|
||||||
expected_backend as _expected_preview_backend,
|
expected_backend as _expected_preview_backend,
|
||||||
)
|
)
|
||||||
@@ -35,7 +43,6 @@ from mediapreview.protocol import PreviewRequest, PreviewResponse
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"PREVIEW_TIMEOUT",
|
"PREVIEW_TIMEOUT",
|
||||||
"PreviewError",
|
"PreviewError",
|
||||||
"PreviewPoolClosedError",
|
|
||||||
"PreviewTimeoutError",
|
"PreviewTimeoutError",
|
||||||
"generate_office_preview",
|
"generate_office_preview",
|
||||||
"is_previewable_path",
|
"is_previewable_path",
|
||||||
@@ -47,33 +54,6 @@ __all__ = [
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PreviewTimeoutError(Exception):
|
|
||||||
"""Raised when the preview subprocess exceeds PREVIEW_TIMEOUT."""
|
|
||||||
|
|
||||||
def __init__(self, message: str, *, backend: str | None = None):
|
|
||||||
super().__init__(message)
|
|
||||||
self.backend = backend
|
|
||||||
|
|
||||||
|
|
||||||
class PreviewError(Exception):
|
|
||||||
"""Raised when the preview subprocess exits with a non-zero status."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
message: str,
|
|
||||||
*,
|
|
||||||
stderr: str | None = None,
|
|
||||||
backend: str | None = None,
|
|
||||||
):
|
|
||||||
super().__init__(message)
|
|
||||||
self.stderr = stderr
|
|
||||||
self.backend = backend
|
|
||||||
|
|
||||||
|
|
||||||
class PreviewPoolClosedError(PreviewError):
|
|
||||||
"""The preview worker pool has been shut down."""
|
|
||||||
|
|
||||||
|
|
||||||
class WorkerChecksumError(Exception):
|
class WorkerChecksumError(Exception):
|
||||||
"""Raised when worker response checksum does not match the packet."""
|
"""Raised when worker response checksum does not match the packet."""
|
||||||
|
|
||||||
@@ -82,6 +62,20 @@ class WorkerProtocolError(Exception):
|
|||||||
"""Raised when worker response packet is malformed."""
|
"""Raised when worker response packet is malformed."""
|
||||||
|
|
||||||
|
|
||||||
|
def _reraise_worker_error(resp: PreviewResponse, payload: bytes) -> None:
|
||||||
|
"""Re-raise the exception the worker sent back in the payload, if any.
|
||||||
|
|
||||||
|
The payload is pickled by our own worker processes (same trust domain), so
|
||||||
|
the original exception type arrives intact on the caller side. Falls back
|
||||||
|
to a plain PreviewBackendError built from the error message.
|
||||||
|
"""
|
||||||
|
if payload:
|
||||||
|
exc = pickle.loads(payload) # noqa: S301 - trusted: our own workers
|
||||||
|
if isinstance(exc, Exception):
|
||||||
|
raise exc
|
||||||
|
raise backend_error(resp.backend or "unknown", resp.error or "preview worker error")
|
||||||
|
|
||||||
|
|
||||||
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
PREVIEW_TIMEOUT = 10.0 # seconds until preview subprocess is killed
|
||||||
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
PREVIEW_WORKERS = max(2, min(8, cpu_count()))
|
||||||
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
|
WORKER_KILL_GRACE = 5.0 # max seconds to wait for a killed worker to be reaped
|
||||||
@@ -139,11 +133,7 @@ class _PreviewWorker:
|
|||||||
|
|
||||||
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
|
resp = msgspec.json.decode(meta_raw, type=PreviewResponse)
|
||||||
if not resp.ok:
|
if not resp.ok:
|
||||||
raise PreviewError(
|
_reraise_worker_error(resp, payload)
|
||||||
resp.error or "preview worker error",
|
|
||||||
stderr=resp.stderr,
|
|
||||||
backend=resp.backend,
|
|
||||||
)
|
|
||||||
return payload or None, resp
|
return payload or None, resp
|
||||||
|
|
||||||
async def kill(self) -> None:
|
async def kill(self) -> None:
|
||||||
@@ -279,9 +269,9 @@ class _PreviewWorkerPool:
|
|||||||
)
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewTimeoutError(
|
preview_timeout_error(
|
||||||
args[0].name,
|
_expected_preview_backend(args[0]),
|
||||||
backend=_expected_preview_backend(args[0]),
|
PREVIEW_TIMEOUT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -305,9 +295,9 @@ class _PreviewWorkerPool:
|
|||||||
)
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewTimeoutError(
|
preview_timeout_error(
|
||||||
filepath.name,
|
_expected_preview_backend(filepath),
|
||||||
backend=_expected_preview_backend(filepath),
|
PREVIEW_TIMEOUT,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except WorkerChecksumError:
|
except WorkerChecksumError:
|
||||||
@@ -319,7 +309,10 @@ class _PreviewWorkerPool:
|
|||||||
)
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewError(f"worker checksum mismatch for {filepath.name}")
|
backend_error(
|
||||||
|
_expected_preview_backend(filepath),
|
||||||
|
f"worker checksum mismatch for {filepath.name}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except PreviewError as e:
|
except PreviewError as e:
|
||||||
if not future.done():
|
if not future.done():
|
||||||
@@ -342,14 +335,20 @@ class _PreviewWorkerPool:
|
|||||||
)
|
)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewError(f"worker protocol failure for {filepath.name}: {e}")
|
backend_error(
|
||||||
|
_expected_preview_backend(filepath),
|
||||||
|
f"worker protocol failure for {filepath.name}: {e}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
replace = True
|
replace = True
|
||||||
logger.exception("Unexpected preview worker error for %s", filepath.name)
|
logger.exception("Unexpected preview worker error for %s", filepath.name)
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(
|
||||||
PreviewError(f"unexpected worker error for {filepath.name}")
|
backend_error(
|
||||||
|
_expected_preview_backend(filepath),
|
||||||
|
f"unexpected worker error for {filepath.name}",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
if replace:
|
if replace:
|
||||||
@@ -378,7 +377,7 @@ class _PreviewWorkerPool:
|
|||||||
data: bytes | None = None,
|
data: bytes | None = None,
|
||||||
):
|
):
|
||||||
if self._closed:
|
if self._closed:
|
||||||
raise PreviewPoolClosedError("preview worker pool closed")
|
raise preview_cancelled_error("preview worker pool closed")
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
future = loop.create_future()
|
future = loop.create_future()
|
||||||
self._in_flight.add(future)
|
self._in_flight.add(future)
|
||||||
@@ -410,18 +409,14 @@ class _PreviewWorkerPool:
|
|||||||
# out their timeouts during server shutdown.
|
# out their timeouts during server shutdown.
|
||||||
for future in list(self._in_flight):
|
for future in list(self._in_flight):
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(preview_cancelled_error("pool closed"))
|
||||||
PreviewPoolClosedError("preview worker pool closed")
|
|
||||||
)
|
|
||||||
while not self._pending.empty():
|
while not self._pending.empty():
|
||||||
try:
|
try:
|
||||||
_priority, _seq, future, _args = self._pending.get_nowait()
|
_priority, _seq, future, _args = self._pending.get_nowait()
|
||||||
except asyncio.QueueEmpty:
|
except asyncio.QueueEmpty:
|
||||||
break
|
break
|
||||||
if not future.done():
|
if not future.done():
|
||||||
future.set_exception(
|
future.set_exception(preview_cancelled_error("pool closed"))
|
||||||
PreviewPoolClosedError("preview worker pool closed")
|
|
||||||
)
|
|
||||||
while not self._idle.empty():
|
while not self._idle.empty():
|
||||||
try:
|
try:
|
||||||
self._idle.get_nowait()
|
self._idle.get_nowait()
|
||||||
@@ -469,7 +464,11 @@ async def shutdown_preview_workers() -> None:
|
|||||||
async def generate_office_preview(
|
async def generate_office_preview(
|
||||||
filepath: Path, quality: int, maxsize: int, maxzoom: float
|
filepath: Path, quality: int, maxsize: int, maxzoom: float
|
||||||
) -> tuple[bytes | None, PreviewResponse | None]:
|
) -> tuple[bytes | None, PreviewResponse | None]:
|
||||||
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion."""
|
"""Generate a preview for an office file using OnlyOffice + worker AVIF conversion.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OnlyOfficeError: If the OnlyOffice Document Server cannot convert the file.
|
||||||
|
"""
|
||||||
manager = get_oo_manager()
|
manager = get_oo_manager()
|
||||||
t_oo_start = perf_counter()
|
t_oo_start = perf_counter()
|
||||||
png_bytes = await manager.convert(filepath)
|
png_bytes = await manager.convert(filepath)
|
||||||
@@ -478,7 +477,7 @@ async def generate_office_preview(
|
|||||||
img, resp = await run_preview(filepath, quality, maxsize, maxzoom, data=png_bytes)
|
img, resp = await run_preview(filepath, quality, maxsize, maxzoom, data=png_bytes)
|
||||||
|
|
||||||
if resp is not None:
|
if resp is not None:
|
||||||
resp.backend = "onlyoffice+" + (resp.backend or "pyvips")
|
resp.backend = "onlyoffice+" + (resp.backend or "vips")
|
||||||
if resp.timings:
|
if resp.timings:
|
||||||
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
|
resp.timings = [round((t_oo_end - t_oo_start) * 1000, 1), *resp.timings]
|
||||||
return img, resp
|
return img, resp
|
||||||
@@ -490,5 +489,5 @@ async def run_preview(
|
|||||||
"""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 PreviewPoolClosedError("preview worker pool closed")
|
raise preview_cancelled_error("preview worker pool closed")
|
||||||
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
|
return await _preview_pool.run(filepath, quality, maxsize, maxzoom, data)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class PreviewRequest(msgspec.Struct, omit_defaults=True):
|
|||||||
|
|
||||||
|
|
||||||
class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||||
ok: bool
|
ok: bool # Indicates whether binary payload is the file or a pickled exception
|
||||||
mime: str | None = None
|
mime: str | None = None
|
||||||
backend: str | None = None
|
backend: str | None = None
|
||||||
timings: list[float] | None = None
|
timings: list[float] | None = None
|
||||||
|
|||||||
@@ -31,3 +31,18 @@ class EmojiFormatter(logging.Formatter):
|
|||||||
|
|
||||||
def format(self, record: logging.LogRecord) -> str:
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
return format_level_prefix(record.levelno) + record.getMessage()
|
return format_level_prefix(record.levelno) + record.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
def quiet_vips_logging() -> None:
|
||||||
|
"""Silence libvips per-operation chatter without hiding deprecations.
|
||||||
|
|
||||||
|
pyvips redirects every GLib message ("VIPS: threadpool completed ...")
|
||||||
|
onto the ``pyvips`` logger at INFO; cap that logger at WARNING. pyvips's
|
||||||
|
own diagnostics (e.g. deprecated-argument notices) are logged on the
|
||||||
|
``pyvips.voperation`` child logger and stay at the default INFO.
|
||||||
|
|
||||||
|
Opt-in: applications that want quiet vips output call this once during
|
||||||
|
their own logging setup. mediapreview never calls it on import.
|
||||||
|
"""
|
||||||
|
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("pyvips.voperation").setLevel(logging.INFO)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import contextlib
|
|||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import pickle
|
||||||
import signal
|
import signal
|
||||||
import struct
|
import struct
|
||||||
import sys
|
import sys
|
||||||
@@ -37,12 +38,25 @@ except ImportError: # pragma: no cover - optional worker extra
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
from mediapreview.backends import dispatch
|
from mediapreview.backends import dispatch
|
||||||
|
from mediapreview.exceptions import PreviewError
|
||||||
from mediapreview.protocol import PreviewRequest, PreviewResponse
|
from mediapreview.protocol import PreviewRequest, PreviewResponse
|
||||||
from mediapreview.util.logformat import format_level_prefix
|
from mediapreview.util.logformat import format_level_prefix
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_exception(e: BaseException) -> bytes:
|
||||||
|
"""Pickle an exception for re-raising across the pool wire.
|
||||||
|
|
||||||
|
Falls back to an empty payload (caller uses the plain error message) in
|
||||||
|
the unlikely case the exception cannot be pickled.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return pickle.dumps(e)
|
||||||
|
except Exception:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
|
||||||
class _WorkerLogFormatter(logging.Formatter):
|
class _WorkerLogFormatter(logging.Formatter):
|
||||||
"""Emoji level prefix like the main process, tagged with the worker pid."""
|
"""Emoji level prefix like the main process, tagged with the worker pid."""
|
||||||
|
|
||||||
@@ -125,21 +139,23 @@ def _run_loop() -> None:
|
|||||||
result, resp = dispatch(
|
result, resp = dispatch(
|
||||||
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
|
Path(req.path), req.quality, req.maxsize, req.maxzoom, data
|
||||||
)
|
)
|
||||||
if not resp.ok:
|
|
||||||
captured = stderr_capture.getvalue().strip()
|
|
||||||
if captured:
|
|
||||||
resp = PreviewResponse(
|
|
||||||
ok=False,
|
|
||||||
backend=resp.backend,
|
|
||||||
error=resp.error,
|
|
||||||
stderr=captured,
|
|
||||||
)
|
|
||||||
_write_response(resp, result or b"")
|
_write_response(resp, result or b"")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("Preview worker error for %s", req.path)
|
# PreviewError is an expected failure (broken input, missing
|
||||||
|
# extra, backend error) — a warning suffices. Tracebacks are
|
||||||
|
# reserved for internal errors we did not anticipate.
|
||||||
|
if isinstance(e, PreviewError):
|
||||||
|
logger.warning("Preview failed for %s: %s", req.path, e)
|
||||||
|
else:
|
||||||
|
logger.exception("Preview worker error for %s", req.path)
|
||||||
captured = stderr_capture.getvalue().strip()
|
captured = stderr_capture.getvalue().strip()
|
||||||
_write_response(
|
_write_response(
|
||||||
PreviewResponse(ok=False, error=str(e), stderr=captured or None), b""
|
PreviewResponse(
|
||||||
|
ok=False,
|
||||||
|
error=str(e),
|
||||||
|
stderr=captured or None,
|
||||||
|
),
|
||||||
|
_serialize_exception(e),
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
root_logger.removeHandler(handler)
|
root_logger.removeHandler(handler)
|
||||||
@@ -155,8 +171,6 @@ def main() -> None:
|
|||||||
handler = logging.StreamHandler(sys.stderr)
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
handler.setFormatter(_WorkerLogFormatter())
|
handler.setFormatter(_WorkerLogFormatter())
|
||||||
logging.basicConfig(level=logging.INFO, handlers=[handler])
|
logging.basicConfig(level=logging.INFO, handlers=[handler])
|
||||||
# pyvips is chatty at INFO ("threadpool completed ..." per operation).
|
|
||||||
logging.getLogger("pyvips").setLevel(logging.WARNING)
|
|
||||||
# NOTE: standalone package no longer depends on cista config loading.
|
# NOTE: standalone package no longer depends on cista config loading.
|
||||||
# Consumers can load their own configuration before starting workers.
|
# Consumers can load their own configuration before starting workers.
|
||||||
if len(sys.argv) > 1:
|
if len(sys.argv) > 1:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 342 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 341 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 344 KiB |
|
After Width: | Height: | Size: 344 KiB |
@@ -0,0 +1,184 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate missing test fixtures for the low-level preview tests.
|
||||||
|
|
||||||
|
This script only creates files that are not already present in tests/files/.
|
||||||
|
Run it whenever you need to rebuild the rotated/HDR video fixtures or the
|
||||||
|
sample PDF.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pymupdf
|
||||||
|
except ImportError as e: # pragma: no cover - optional pdf extra
|
||||||
|
raise ImportError(
|
||||||
|
"Fixture generation requires pymupdf: pip install mediapreview[pdf]"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
FILES = ROOT
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def run(cmd: list[str]) -> None:
|
||||||
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_box(
|
||||||
|
data: bytearray, box_type: bytes, start: int = 0, end: int | None = None
|
||||||
|
) -> int:
|
||||||
|
end = end or len(data)
|
||||||
|
i = start
|
||||||
|
while i + 8 <= end:
|
||||||
|
size = int.from_bytes(data[i : i + 4], "big")
|
||||||
|
btype = data[i + 4 : i + 8]
|
||||||
|
if btype == box_type:
|
||||||
|
return i
|
||||||
|
if size == 0:
|
||||||
|
break
|
||||||
|
if size == 1:
|
||||||
|
size = int.from_bytes(data[i + 8 : i + 16], "big")
|
||||||
|
if size < 8:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid box size {size} for {btype.decode('ascii', errors='replace')}"
|
||||||
|
)
|
||||||
|
i += size
|
||||||
|
return -1
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_90_cw() -> list[int]:
|
||||||
|
return [0, 1 << 16, 0, -(1 << 16), 0, 0, 0, 0, 1 << 16]
|
||||||
|
|
||||||
|
|
||||||
|
def _matrix_270_cw() -> list[int]:
|
||||||
|
return [0, -(1 << 16), 0, 1 << 16, 0, 0, 0, 0, 1 << 16]
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_tkhd_rotation(in_path: Path, out_path: Path, degrees: int) -> None:
|
||||||
|
"""Write a copy of *in_path* with the video track display matrix rotated."""
|
||||||
|
data = bytearray(in_path.read_bytes())
|
||||||
|
|
||||||
|
moov_idx = _find_box(data, b"moov")
|
||||||
|
if moov_idx < 0:
|
||||||
|
raise ValueError("moov box not found")
|
||||||
|
moov_end = moov_idx + int.from_bytes(data[moov_idx : moov_idx + 4], "big")
|
||||||
|
|
||||||
|
trak_idx = _find_box(data, b"trak", moov_idx + 8, moov_end)
|
||||||
|
while trak_idx >= 0:
|
||||||
|
trak_size = int.from_bytes(data[trak_idx : trak_idx + 4], "big")
|
||||||
|
trak_end = trak_idx + trak_size
|
||||||
|
mdia_idx = _find_box(data, b"mdia", trak_idx + 8, trak_end)
|
||||||
|
if mdia_idx < 0:
|
||||||
|
trak_idx = _find_box(data, b"trak", trak_end, moov_end)
|
||||||
|
continue
|
||||||
|
mdia_end = mdia_idx + int.from_bytes(data[mdia_idx : mdia_idx + 4], "big")
|
||||||
|
hdlr_idx = _find_box(data, b"hdlr", mdia_idx + 8, mdia_end)
|
||||||
|
if hdlr_idx < 0:
|
||||||
|
trak_idx = _find_box(data, b"trak", trak_end, moov_end)
|
||||||
|
continue
|
||||||
|
handler_type = data[hdlr_idx + 16 : hdlr_idx + 20]
|
||||||
|
if handler_type == b"vide":
|
||||||
|
tkhd_idx = _find_box(data, b"tkhd", trak_idx + 8, trak_end)
|
||||||
|
if tkhd_idx < 0:
|
||||||
|
raise ValueError("video track has no tkhd")
|
||||||
|
version = data[tkhd_idx + 8]
|
||||||
|
if version != 0:
|
||||||
|
raise ValueError(f"unsupported tkhd version {version}")
|
||||||
|
matrix_offset = tkhd_idx + 48
|
||||||
|
matrix = _matrix_90_cw() if degrees == 90 else _matrix_270_cw()
|
||||||
|
for i, val in enumerate(matrix):
|
||||||
|
data[matrix_offset + i * 4 : matrix_offset + (i + 1) * 4] = (
|
||||||
|
val.to_bytes(4, "big", signed=True)
|
||||||
|
)
|
||||||
|
out_path.write_bytes(data)
|
||||||
|
return
|
||||||
|
trak_idx = _find_box(data, b"trak", trak_end, moov_end)
|
||||||
|
raise ValueError("video track not found")
|
||||||
|
|
||||||
|
|
||||||
|
def generate_pdf() -> None:
|
||||||
|
pdf_path = FILES / "sample.pdf"
|
||||||
|
if pdf_path.exists():
|
||||||
|
return
|
||||||
|
doc = pymupdf.open()
|
||||||
|
page = doc.new_page(width=595, height=842)
|
||||||
|
page.insert_text((100, 100), "Hello, PDF preview test!")
|
||||||
|
page.draw_rect((100, 150, 400, 250), color=(1, 0, 0), width=2)
|
||||||
|
doc.save(str(pdf_path))
|
||||||
|
doc.close()
|
||||||
|
logger.info("generated %s", pdf_path)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_sdr_rotated() -> None:
|
||||||
|
source = FILES / "sample-1mb.mp4"
|
||||||
|
if not source.exists():
|
||||||
|
raise FileNotFoundError(f"Missing source fixture: {source}")
|
||||||
|
for degrees in (90, 270):
|
||||||
|
out = FILES / f"rotated_{degrees}.mp4"
|
||||||
|
if out.exists():
|
||||||
|
continue
|
||||||
|
_patch_tkhd_rotation(source, out, degrees)
|
||||||
|
logger.info("generated %s", out)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hdr_video() -> None:
|
||||||
|
out = FILES / "hdr_video.mp4"
|
||||||
|
if out.exists():
|
||||||
|
return
|
||||||
|
run(
|
||||||
|
[
|
||||||
|
"ffmpeg",
|
||||||
|
"-y",
|
||||||
|
"-hide_banner",
|
||||||
|
"-loglevel",
|
||||||
|
"error",
|
||||||
|
"-nostdin",
|
||||||
|
"-f",
|
||||||
|
"lavfi",
|
||||||
|
"-i",
|
||||||
|
"testsrc=duration=1:size=320x240:rate=30",
|
||||||
|
"-c:v",
|
||||||
|
"libx265",
|
||||||
|
"-preset",
|
||||||
|
"fast",
|
||||||
|
"-crf",
|
||||||
|
"30",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuv420p10le",
|
||||||
|
"-x265-params",
|
||||||
|
"colorprim=bt2020:transfer=smpte2084:colormatrix=bt2020nc:repeat-headers=1",
|
||||||
|
"-an",
|
||||||
|
str(out),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
logger.info("generated %s", out)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_hdr_rotated() -> None:
|
||||||
|
source = FILES / "hdr_video.mp4"
|
||||||
|
if not source.exists():
|
||||||
|
raise FileNotFoundError(f"Generate hdr_video.mp4 first: {source}")
|
||||||
|
for degrees in (90, 270):
|
||||||
|
out = FILES / f"hdr_rotated_{degrees}.mp4"
|
||||||
|
if out.exists():
|
||||||
|
continue
|
||||||
|
_patch_tkhd_rotation(source, out, degrees)
|
||||||
|
logger.info("generated %s", out)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||||
|
generate_pdf()
|
||||||
|
generate_sdr_rotated()
|
||||||
|
generate_hdr_video()
|
||||||
|
generate_hdr_rotated()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
After Width: | Height: | Size: 364 KiB |
@@ -0,0 +1,119 @@
|
|||||||
|
"""Tests for OnlyOffice error handling and structured responses."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mediapreview import office
|
||||||
|
from mediapreview.exceptions import (
|
||||||
|
OnlyOfficeError,
|
||||||
|
PreviewBackendError,
|
||||||
|
backend_error,
|
||||||
|
onlyoffice_error_from_code,
|
||||||
|
onlyoffice_http_error,
|
||||||
|
onlyoffice_no_fileurl_error,
|
||||||
|
onlyoffice_unavailable_error,
|
||||||
|
)
|
||||||
|
from mediapreview.pool import generate_office_preview
|
||||||
|
|
||||||
|
FILES = Path(__file__).resolve().parent / "files"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("code", "short", "message"),
|
||||||
|
[
|
||||||
|
("-8", "jwt error", "OnlyOffice JWT authentication failed"),
|
||||||
|
("-4", "input error", "OnlyOffice input error"),
|
||||||
|
("-2", "timeout error", "OnlyOffice conversion timed out"),
|
||||||
|
("-1", "unknown error", "OnlyOffice conversion failed with unknown error"),
|
||||||
|
("-99", "-99 error", "OnlyOffice conversion failed: -99"),
|
||||||
|
(None, "unknown error", "OnlyOffice conversion failed with unknown error"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_onlyoffice_error_from_code(code, short, message):
|
||||||
|
"""Conversion status codes map to clean labels and messages."""
|
||||||
|
err = onlyoffice_error_from_code(code)
|
||||||
|
assert str(err) == message
|
||||||
|
assert err.short == short
|
||||||
|
assert err.code == code
|
||||||
|
assert err.backend == "onlyoffice"
|
||||||
|
assert isinstance(err, OnlyOfficeError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_onlyoffice_http_error():
|
||||||
|
err = onlyoffice_http_error(502)
|
||||||
|
assert str(err) == "OnlyOffice HTTP error: 502"
|
||||||
|
assert err.status == 502
|
||||||
|
assert err.short == "http error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_onlyoffice_unavailable_error():
|
||||||
|
err = onlyoffice_unavailable_error("http://localhost:8988")
|
||||||
|
assert "http://localhost:8988" in str(err)
|
||||||
|
assert err.url == "http://localhost:8988"
|
||||||
|
assert err.short == "unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_onlyoffice_no_fileurl_error():
|
||||||
|
err = onlyoffice_no_fileurl_error("<empty />")
|
||||||
|
assert "<empty />" in str(err)
|
||||||
|
assert err.snippet == "<empty />"
|
||||||
|
assert err.short == "no-fileurl error"
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_error_pipeline_backend():
|
||||||
|
"""Combined pipelines report the failing step in the backend name."""
|
||||||
|
err = backend_error("pdf", "cannot read document")
|
||||||
|
assert err.backend == "pdf"
|
||||||
|
assert err.short == "cannot read document"
|
||||||
|
assert isinstance(err, PreviewBackendError)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backend_error_short_message_strips_source_and_detail():
|
||||||
|
"""Backend messages like "source: summary: detail" become just the summary."""
|
||||||
|
err = backend_error(
|
||||||
|
"vips",
|
||||||
|
"pyvips: cannot decode image: unable to load from file b'/mnt/c/Users...",
|
||||||
|
)
|
||||||
|
assert err.short == "cannot decode image"
|
||||||
|
|
||||||
|
|
||||||
|
def test_error_pickle_round_trip():
|
||||||
|
"""Exceptions survive pickling (the worker pool wire) intact."""
|
||||||
|
err = onlyoffice_error_from_code("-8")
|
||||||
|
restored = pickle.loads(pickle.dumps(err))
|
||||||
|
assert type(restored) is type(err)
|
||||||
|
assert str(restored) == str(err)
|
||||||
|
assert restored.code == err.code
|
||||||
|
assert restored.short == err.short
|
||||||
|
assert restored.backend == err.backend
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_office_preview_raises_structured_error(monkeypatch):
|
||||||
|
"""On OnlyOffice failure, generate_office_preview raises OnlyOfficeError."""
|
||||||
|
|
||||||
|
async def fake_convert(_filepath: Path, request_timeout: float = 5.0) -> bytes:
|
||||||
|
raise onlyoffice_error_from_code("-8")
|
||||||
|
|
||||||
|
async def fake_available() -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(office, "convert_to_png_async", fake_convert)
|
||||||
|
monkeypatch.setattr(office, "is_available_cached", fake_available)
|
||||||
|
|
||||||
|
with pytest.raises(OnlyOfficeError) as exc_info:
|
||||||
|
await generate_office_preview(
|
||||||
|
FILES / "file-sample_100kB.docx",
|
||||||
|
quality=60,
|
||||||
|
maxsize=512,
|
||||||
|
maxzoom=2.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
err = exc_info.value
|
||||||
|
assert err.code == "-8"
|
||||||
|
assert err.short == "jwt error"
|
||||||
|
assert str(err) == "OnlyOffice JWT authentication failed"
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Success tests for low-level preview conversion functions.
|
||||||
|
|
||||||
|
These tests exercise the concrete backend converters (image, video, PDF,
|
||||||
|
office) against the fixture files in tests/files/. The only thing they
|
||||||
|
assert is that the conversion succeeds and returns non-empty AVIF bytes, plus
|
||||||
|
basic metadata sanity checks.
|
||||||
|
|
||||||
|
Office tests are skipped unless an OnlyOffice Document Server is reachable.
|
||||||
|
Configure them with environment variables before running pytest:
|
||||||
|
|
||||||
|
ONLYOFFICE_URL=http://localhost:8988
|
||||||
|
ONLYOFFICE_JWT_SECRET=<same-secret-you-gave-the-oo-container>
|
||||||
|
ONLYOFFICE_CALLBACK_HOST=<host the OO container can reach>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from mediapreview import dispatch
|
||||||
|
from mediapreview.backends.image import (
|
||||||
|
process_image,
|
||||||
|
process_image_buffer,
|
||||||
|
process_image_pyvips,
|
||||||
|
)
|
||||||
|
from mediapreview.backends.pdf import process_pdf
|
||||||
|
from mediapreview.backends.video import process_video
|
||||||
|
from mediapreview.exceptions import PreviewBackendError
|
||||||
|
from mediapreview.office import is_available_async
|
||||||
|
from mediapreview.pool import generate_office_preview
|
||||||
|
|
||||||
|
FILES = Path(__file__).resolve().parent / "files"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_ok(data, resp, backend: str | None = None) -> None:
|
||||||
|
assert resp.ok, f"conversion failed: {resp.error}"
|
||||||
|
assert data
|
||||||
|
assert resp.mime == "image/avif"
|
||||||
|
if backend:
|
||||||
|
assert resp.backend == backend
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Images
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path", sorted(FILES.glob("Landscape_*.jpg")))
|
||||||
|
def test_process_image_exif_orientations(path: Path) -> None:
|
||||||
|
"""Every EXIF orientation fixture must produce a valid preview."""
|
||||||
|
data, resp = process_image(path, maxsize=512, quality=60)
|
||||||
|
_assert_ok(data, resp, backend="vips")
|
||||||
|
assert resp.width in (1200, 1800)
|
||||||
|
assert resp.height in (1200, 1800)
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_image_hdr_avif() -> None:
|
||||||
|
"""HDR AVIF images are routed through ffmpeg to preserve colour metadata."""
|
||||||
|
path = FILES / "hdr_cosmos01650_cicp9-16-9_yuv444_full_qp10.avif"
|
||||||
|
data, resp = process_image(path, maxsize=512, quality=60)
|
||||||
|
_assert_ok(data, resp, backend="ffmpeg")
|
||||||
|
assert resp.width is not None
|
||||||
|
assert resp.height is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_image_pyvips() -> None:
|
||||||
|
"""The pyvips-only image backend works on a plain JPEG."""
|
||||||
|
path = FILES / "Landscape_1.jpg"
|
||||||
|
data, resp = process_image_pyvips(path, maxsize=512, quality=60)
|
||||||
|
_assert_ok(data, resp, backend="vips")
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_image_buffer() -> None:
|
||||||
|
"""Processing a JPEG from a buffer produces the same valid preview."""
|
||||||
|
path = FILES / "Landscape_1.jpg"
|
||||||
|
data, resp = process_image_buffer(
|
||||||
|
path.read_bytes(), maxsize=512, quality=60, maxzoom=2.0
|
||||||
|
)
|
||||||
|
_assert_ok(data, resp, backend="vips")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Videos
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
VIDEO_FIXTURES = [
|
||||||
|
("sample-1mb.mp4", 854, 480),
|
||||||
|
("rotated_90.mp4", 480, 854),
|
||||||
|
("rotated_270.mp4", 480, 854),
|
||||||
|
("hdr_video.mp4", 320, 240),
|
||||||
|
("hdr_rotated_90.mp4", 240, 320),
|
||||||
|
("hdr_rotated_270.mp4", 240, 320),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("filename", "expected_width", "expected_height"),
|
||||||
|
VIDEO_FIXTURES,
|
||||||
|
ids=[f[0] for f in VIDEO_FIXTURES],
|
||||||
|
)
|
||||||
|
def test_process_video(
|
||||||
|
filename: str, expected_width: int, expected_height: int
|
||||||
|
) -> None:
|
||||||
|
"""SDR and HDR video clips, with and without rotation, convert successfully."""
|
||||||
|
data, resp = process_video(FILES / filename, maxsize=512, quality=60)
|
||||||
|
_assert_ok(data, resp, backend="video")
|
||||||
|
assert resp.width == expected_width
|
||||||
|
assert resp.height == expected_height
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PDF
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_process_pdf() -> None:
|
||||||
|
"""A simple single-page PDF converts to a valid preview."""
|
||||||
|
data, resp = process_pdf(
|
||||||
|
FILES / "sample.pdf",
|
||||||
|
maxsize=512,
|
||||||
|
maxzoom=2.0,
|
||||||
|
quality=60,
|
||||||
|
)
|
||||||
|
_assert_ok(data, resp, backend="pdf+vips")
|
||||||
|
assert resp.width == 595
|
||||||
|
assert resp.height == 842
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public dispatch entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
DISPATCH_FIXTURES = [
|
||||||
|
("Landscape_1.jpg", "vips", 1800, 1200),
|
||||||
|
("sample-1mb.mp4", "video", 854, 480),
|
||||||
|
("sample.pdf", "pdf+vips", 595, 842),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("filename", "backend", "expected_width", "expected_height"),
|
||||||
|
DISPATCH_FIXTURES,
|
||||||
|
ids=[f[0] for f in DISPATCH_FIXTURES],
|
||||||
|
)
|
||||||
|
def test_dispatch(
|
||||||
|
filename: str, backend: str, expected_width: int, expected_height: int
|
||||||
|
) -> None:
|
||||||
|
"""The public dispatch() wrapper routes to the correct backend and succeeds."""
|
||||||
|
data, resp = dispatch(FILES / filename, quality=60, maxsize=512, maxzoom=2.0)
|
||||||
|
_assert_ok(data, resp, backend=backend)
|
||||||
|
assert resp.width == expected_width
|
||||||
|
assert resp.height == expected_height
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_office(monkeypatch) -> None:
|
||||||
|
"""dispatch() converts office documents via OnlyOffice when called directly."""
|
||||||
|
fake_png = (FILES / "Landscape_1.jpg").read_bytes()
|
||||||
|
|
||||||
|
class _FakeManager:
|
||||||
|
async def convert(self, filepath: Path) -> bytes:
|
||||||
|
assert filepath == FILES / "file-sample_100kB.docx"
|
||||||
|
return fake_png
|
||||||
|
|
||||||
|
async def _noop() -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("mediapreview.office.get_oo_manager", _FakeManager)
|
||||||
|
monkeypatch.setattr("mediapreview.office.close_oo_client", _noop)
|
||||||
|
|
||||||
|
data, resp = dispatch(
|
||||||
|
FILES / "file-sample_100kB.docx",
|
||||||
|
quality=60,
|
||||||
|
maxsize=512,
|
||||||
|
maxzoom=2.0,
|
||||||
|
)
|
||||||
|
_assert_ok(data, resp)
|
||||||
|
assert resp.backend == "onlyoffice+vips"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_unknown_extension(tmp_path: Path) -> None:
|
||||||
|
"""Unsupported extensions produce a diagnostic naming the extension."""
|
||||||
|
path = tmp_path / "unknown-file.xyz"
|
||||||
|
path.write_text("not a previewable file")
|
||||||
|
with pytest.raises(PreviewBackendError) as exc_info:
|
||||||
|
dispatch(path, quality=60, maxsize=512, maxzoom=2.0)
|
||||||
|
assert "unknown file extension: '.xyz'" in str(exc_info.value)
|
||||||
|
assert exc_info.value.backend == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_no_extension(tmp_path: Path) -> None:
|
||||||
|
"""Files without an extension produce a diagnostic saying so."""
|
||||||
|
path = tmp_path / "unknown-file-no-ext"
|
||||||
|
path.write_text("not a previewable file")
|
||||||
|
with pytest.raises(PreviewBackendError) as exc_info:
|
||||||
|
dispatch(path, quality=60, maxsize=512, maxzoom=2.0)
|
||||||
|
assert "unknown file type: no file extension" in str(exc_info.value)
|
||||||
|
assert exc_info.value.backend == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Office previews via OnlyOffice
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_office_preview() -> None:
|
||||||
|
"""DOCX preview through OnlyOffice.
|
||||||
|
|
||||||
|
Skipped unless an OnlyOffice server is reachable and ONLYOFFICE_JWT_SECRET
|
||||||
|
is set to the same secret the OO container is using. The shared secret must
|
||||||
|
be at least 32 bytes long so PyJWT doesn't warn about weak HMAC keys.
|
||||||
|
"""
|
||||||
|
secret = os.environ.get("ONLYOFFICE_JWT_SECRET", "")
|
||||||
|
if len(secret.encode()) < 32:
|
||||||
|
pytest.skip("ONLYOFFICE_JWT_SECRET not set or shorter than 32 bytes")
|
||||||
|
if not await is_available_async():
|
||||||
|
pytest.skip("OnlyOffice Document Server not reachable")
|
||||||
|
|
||||||
|
data, resp = await generate_office_preview(
|
||||||
|
FILES / "file-sample_100kB.docx",
|
||||||
|
quality=60,
|
||||||
|
maxsize=512,
|
||||||
|
maxzoom=2.0,
|
||||||
|
)
|
||||||
|
assert data is not None
|
||||||
|
assert resp is not None
|
||||||
|
_assert_ok(data, resp)
|
||||||
|
assert resp.width is not None
|
||||||
|
assert resp.height is not None
|
||||||