Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a1dd2b7da | ||
|
|
3df6b079c9 | ||
|
|
af804e2c9f | ||
|
|
1dc0c4441a | ||
|
|
c7ba0d5a04 | ||
|
|
0071058b29 | ||
|
|
338c74de69 | ||
|
|
b13f08eab2 | ||
|
|
5d566d6deb |
+3
-1
@@ -185,7 +185,9 @@ def _resolve_confdir(args):
|
||||
|
||||
def _confdir(args):
|
||||
confdir = _resolve_confdir(args)
|
||||
config.init_confdir(confdir)
|
||||
if confdir is not None:
|
||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||
config.init_confdir()
|
||||
|
||||
|
||||
def _user(args):
|
||||
|
||||
+10
-6
@@ -148,12 +148,16 @@ async def main_after_start(app):
|
||||
# Sanic sometimes fails to execute after_server_stop, so we do it before instead (potentially interrupting handlers)
|
||||
@app.before_server_stop
|
||||
async def main_stop(app):
|
||||
watching.stop(app)
|
||||
await onlyoffice.close_oo_client()
|
||||
await shutdown_preview_workers()
|
||||
app.ctx.threadexec.shutdown()
|
||||
app.ctx.zipexec.shutdown(cancel_futures=True)
|
||||
await sso.close_client()
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(asyncio.to_thread(watching.stop, app))
|
||||
tg.create_task(onlyoffice.close_oo_client())
|
||||
tg.create_task(shutdown_preview_workers())
|
||||
tg.create_task(sso.close_client())
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(asyncio.to_thread(app.ctx.threadexec.shutdown))
|
||||
tg.create_task(asyncio.to_thread(app.ctx.zipexec.shutdown, cancel_futures=True))
|
||||
|
||||
logger.debug("Cista worker threads all finished")
|
||||
|
||||
|
||||
|
||||
+3
-3
@@ -63,10 +63,10 @@ config: Config
|
||||
conffile: Path
|
||||
|
||||
|
||||
def init_confdir(confdir: Path | str | None = None) -> None:
|
||||
def init_confdir() -> None:
|
||||
global conffile
|
||||
if confdir is not None:
|
||||
home = Path(confdir).expanduser()
|
||||
if p := os.environ.get("CISTA_HOME"):
|
||||
home = Path(p)
|
||||
else:
|
||||
xdg = os.environ.get("XDG_CONFIG_HOME")
|
||||
home = (
|
||||
|
||||
+3
-1
@@ -129,7 +129,9 @@ def log_reachable_info() -> None:
|
||||
|
||||
def setup_docker(confdir: Path | None = None) -> int:
|
||||
"""Build and run the patched OnlyOffice Docker image."""
|
||||
config.init_confdir(confdir)
|
||||
if confdir is not None:
|
||||
os.environ["CISTA_HOME"] = confdir.as_posix()
|
||||
config.init_confdir()
|
||||
if config.conffile.exists():
|
||||
config.load_config()
|
||||
else:
|
||||
|
||||
+48
-11
@@ -20,7 +20,8 @@ from sanic import Blueprint, empty, raw, redirect
|
||||
from sanic.exceptions import NotFound
|
||||
from sanic.log import logger
|
||||
|
||||
from cista import auth, config, onlyoffice, sharefs
|
||||
from cista import auth, config, onlyoffice, sharefs, watching
|
||||
from cista.fileio import fuid
|
||||
from cista.preview_worker import (
|
||||
DOC_PREVIEW_SUFFIXES,
|
||||
OFFICE_PREVIEW_SUFFIXES,
|
||||
@@ -244,7 +245,12 @@ class _PreviewWorkerPool:
|
||||
args[0].name,
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(PreviewTimeoutError(args[0].name))
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
args[0].name,
|
||||
backend=_expected_preview_backend(args[0]),
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
filepath = args[0]
|
||||
@@ -258,11 +264,13 @@ class _PreviewWorkerPool:
|
||||
future.set_result((out, resp))
|
||||
except TimeoutError:
|
||||
replace = True
|
||||
logger.warning(
|
||||
"Preview timeout (%ds) for %s", int(PREVIEW_TIMEOUT), filepath.name
|
||||
)
|
||||
if not future.done():
|
||||
future.set_exception(PreviewTimeoutError(filepath.name))
|
||||
future.set_exception(
|
||||
PreviewTimeoutError(
|
||||
filepath.name,
|
||||
backend=_expected_preview_backend(filepath),
|
||||
)
|
||||
)
|
||||
except WorkerChecksumError:
|
||||
replace = True
|
||||
logger.error("Preview checksum mismatch for %s", filepath.name)
|
||||
@@ -410,6 +418,10 @@ async def verify_preview(request):
|
||||
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."""
|
||||
@@ -466,11 +478,13 @@ class OOConversionManager:
|
||||
filepath, request_timeout=5.0
|
||||
)
|
||||
except Exception as e:
|
||||
future.set_exception(e)
|
||||
if not future.done():
|
||||
future.set_exception(e)
|
||||
async with self._lock:
|
||||
self._in_flight.pop(key, None)
|
||||
else:
|
||||
future.set_result(png_bytes)
|
||||
if not future.done():
|
||||
future.set_result(png_bytes)
|
||||
async with self._lock:
|
||||
self._in_flight.pop(key, None)
|
||||
|
||||
@@ -548,6 +562,21 @@ def _preview_job_priority(path) -> int:
|
||||
return 4
|
||||
|
||||
|
||||
def _expected_preview_backend(path: Path) -> str:
|
||||
"""Best-effort backend label used for timeout/access logging."""
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||
return "onlyoffice"
|
||||
if suffix in DOC_PREVIEW_SUFFIXES:
|
||||
return "pdf"
|
||||
mime_type, _ = mimetypes.guess_type(path.name)
|
||||
if mime_type and mime_type.startswith("video/"):
|
||||
return "video"
|
||||
if mime_type and mime_type.startswith("image/"):
|
||||
return "pyvips"
|
||||
return "preview"
|
||||
|
||||
|
||||
def is_previewable_path(path) -> bool:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in DOC_PREVIEW_SUFFIXES or suffix in OFFICE_PREVIEW_SUFFIXES:
|
||||
@@ -609,10 +638,12 @@ async def preview(req, path):
|
||||
timeout=PREVIEW_TIMEOUT,
|
||||
)
|
||||
except TimeoutError:
|
||||
logger.warning("Preview timeout for %s", filepath)
|
||||
req.ctx.log_extra = f"{_expected_preview_backend(filepath)} timeout"
|
||||
return empty(503)
|
||||
except PreviewTimeoutError:
|
||||
logger.warning("Preview worker timeout for %s", filepath)
|
||||
except PreviewTimeoutError as e:
|
||||
req.ctx.log_extra = (
|
||||
f"{(e.backend or _expected_preview_backend(filepath))} timeout"
|
||||
)
|
||||
return empty(503)
|
||||
except httpx.HTTPStatusError:
|
||||
req.ctx.log_extra = "onlyoffice N/A"
|
||||
@@ -654,6 +685,12 @@ async def preview(req, path):
|
||||
# Preview generation failed, redirect to the file itself
|
||||
return redirect(f"/files/{path}", status=303)
|
||||
|
||||
# Store aspect ratio if the worker returned dimensions
|
||||
if preview_resp and preview_resp.width and preview_resp.height:
|
||||
ar = round(preview_resp.height / preview_resp.width, 2)
|
||||
fuid_str = fuid(stat)
|
||||
watching.notify_ar(fuid_str, ar)
|
||||
|
||||
# Build headers and cache the full response
|
||||
preview_mime = (
|
||||
preview_resp.mime
|
||||
|
||||
+37
-1
@@ -17,6 +17,7 @@ import gc
|
||||
import io
|
||||
import logging
|
||||
import mimetypes
|
||||
import shlex
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -95,6 +96,8 @@ class PreviewResponse(msgspec.Struct, omit_defaults=True):
|
||||
timings: list[float] | None = None
|
||||
error: str | None = None
|
||||
stderr: str | None = None
|
||||
width: int | None = None
|
||||
height: int | None = None
|
||||
|
||||
|
||||
_enc = msgspec.json.Encoder()
|
||||
@@ -173,6 +176,7 @@ def _get_image_dimensions(path: Path) -> tuple[int, int] | None:
|
||||
"""
|
||||
try:
|
||||
img = pyvips.Image.new_from_file(str(path))
|
||||
img = img.autorot()
|
||||
except pyvips.error.Error:
|
||||
return None
|
||||
else:
|
||||
@@ -214,7 +218,18 @@ def _image_via_ffmpeg(path: Path, maxsize: int, quality: int) -> bytes:
|
||||
cmd.insert(4, "-s")
|
||||
cmd.insert(5, f"{new_w}x{new_h}")
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
||||
try:
|
||||
subprocess.run(cmd, capture_output=True, check=True, shell=False) # noqa: S603
|
||||
except subprocess.CalledProcessError as e:
|
||||
shell_cmd = shlex.join(cmd)
|
||||
stderr = (e.stderr or b"").decode(errors="replace").strip()
|
||||
if stderr:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}\n{stderr}"
|
||||
) from e
|
||||
raise RuntimeError(
|
||||
f"ffmpeg failed (exit {e.returncode}): {shell_cmd}"
|
||||
) from e
|
||||
with Path(tmp_path).open("rb") as f:
|
||||
return f.read()
|
||||
finally:
|
||||
@@ -228,6 +243,8 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
# HEIC/HEIF: ffmpeg handles tile assembly and HDR correctly;
|
||||
# skip pyvips entirely.
|
||||
if suffix in (".heic", ".heif"):
|
||||
heic_dims = _get_image_dimensions(path)
|
||||
width, height = heic_dims or (None, None)
|
||||
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||
t_end = perf_counter()
|
||||
return ret, PreviewResponse(
|
||||
@@ -235,13 +252,17 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
mime="image/avif",
|
||||
backend="ffmpeg",
|
||||
timings=[round((t_end - t_start) * 1000, 1)],
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
|
||||
# Other image formats: pyvips first, ffmpeg fallback.
|
||||
load_opts = {"access": "sequential"}
|
||||
orig_w = orig_h = None
|
||||
try:
|
||||
img = pyvips.Image.new_from_file(str(path), **load_opts)
|
||||
img = img.autorot()
|
||||
orig_w, orig_h = img.width, img.height
|
||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||
if scale < 1.0:
|
||||
img = img.resize(scale)
|
||||
@@ -253,6 +274,7 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
)
|
||||
backend = "pyvips"
|
||||
except pyvips.error.Error:
|
||||
orig_w, orig_h = None, None
|
||||
ret = _image_via_ffmpeg(path, maxsize, quality)
|
||||
backend = "ffmpeg"
|
||||
t_end = perf_counter()
|
||||
@@ -262,6 +284,8 @@ def process_image_pyvips(path, *, maxsize, quality):
|
||||
mime="image/avif",
|
||||
backend=backend,
|
||||
timings=[round((t_end - t_start) * 1000, 1)],
|
||||
width=orig_w,
|
||||
height=orig_h,
|
||||
)
|
||||
|
||||
|
||||
@@ -270,6 +294,7 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||
t_start = perf_counter()
|
||||
img = pyvips.Image.new_from_buffer(data, "")
|
||||
img = img.autorot()
|
||||
orig_w, orig_h = img.width, img.height
|
||||
scale = min(maxsize / img.width, maxsize / img.height, 1.0)
|
||||
if scale < 1.0:
|
||||
img = img.resize(scale)
|
||||
@@ -286,6 +311,8 @@ def process_image_buffer(data: bytes, *, quality, maxsize, maxzoom):
|
||||
mime="image/avif",
|
||||
backend="pyvips",
|
||||
timings=[round((t_end - t_start) * 1000, 1)],
|
||||
width=orig_w,
|
||||
height=orig_h,
|
||||
)
|
||||
|
||||
|
||||
@@ -315,6 +342,8 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
|
||||
round((t_load_end - t_load_start) * 1000, 1),
|
||||
round((t_save_end - t_save_start) * 1000, 1),
|
||||
],
|
||||
width=round(w),
|
||||
height=round(h),
|
||||
)
|
||||
|
||||
|
||||
@@ -347,6 +376,11 @@ def process_video(path, *, maxsize, quality):
|
||||
raise RuntimeError("No frames found in video")
|
||||
|
||||
# Resize frame to thumbnail size
|
||||
# Capture display dimensions before resize (accounting for rotation)
|
||||
disp_w = frame.width
|
||||
disp_h = frame.height
|
||||
if frame.rotation in (90, 270):
|
||||
disp_w, disp_h = disp_h, disp_w
|
||||
if frame.width > maxsize or frame.height > maxsize:
|
||||
scale_factor = min(maxsize / frame.width, maxsize / frame.height)
|
||||
new_width = int(frame.width * scale_factor)
|
||||
@@ -442,6 +476,8 @@ def process_video(path, *, maxsize, quality):
|
||||
round((t_load_end - t_load_start) * 1000, 1),
|
||||
round((t_save_end - t_save_start) * 1000, 1),
|
||||
],
|
||||
width=disp_w,
|
||||
height=disp_h,
|
||||
)
|
||||
del imgdata, istream, ostream, icc, occ, frame
|
||||
gc.collect()
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ class ErrorMsg(msgspec.Struct):
|
||||
## Directory listings
|
||||
|
||||
|
||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
class FileEntry(msgspec.Struct, array_like=True, frozen=True, omit_defaults=True):
|
||||
level: int
|
||||
name: str
|
||||
key: str
|
||||
@@ -20,6 +20,7 @@ class FileEntry(msgspec.Struct, array_like=True, frozen=True):
|
||||
size: int
|
||||
allocated: int
|
||||
isfile: int
|
||||
ar: float | None = None
|
||||
|
||||
def __str__(self):
|
||||
return self.key or "FileEntry()"
|
||||
|
||||
+40
-1
@@ -1,6 +1,7 @@
|
||||
"""Custom access logging middleware for Sanic."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from ipaddress import IPv6Address
|
||||
@@ -9,6 +10,40 @@ from sanic.log import LOGGING_CONFIG_DEFAULTS
|
||||
|
||||
logger = logging.getLogger("cista.access")
|
||||
|
||||
|
||||
class ReentrantSafeStreamHandler(logging.StreamHandler):
|
||||
"""Stream handler that degrades gracefully on signal-time reentrant writes.
|
||||
|
||||
Python's buffered text streams are not reentrant. If a signal handler logs
|
||||
while another log write is in progress, StreamHandler.emit can raise:
|
||||
RuntimeError("reentrant call inside <_io.BufferedWriter ...>")
|
||||
|
||||
Instead of letting logging emit a long "--- Logging error ---" traceback,
|
||||
we fall back to a best-effort os.write to the same file descriptor.
|
||||
"""
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
msg = ""
|
||||
try:
|
||||
msg = self.format(record)
|
||||
stream = self.stream
|
||||
stream.write(msg + self.terminator)
|
||||
self.flush()
|
||||
except RuntimeError as exc:
|
||||
if "reentrant call inside" not in str(exc):
|
||||
self.handleError(record)
|
||||
return
|
||||
stream = self.stream
|
||||
fd = stream.fileno()
|
||||
encoding = getattr(stream, "encoding", None) or "utf-8"
|
||||
data = (msg + self.terminator).encode(encoding, errors="replace")
|
||||
os.write(fd, data)
|
||||
except RecursionError:
|
||||
raise
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
_RESET = "\033[0m"
|
||||
_STATUS_INFO = "\033[32m" # 1xx (green)
|
||||
_STATUS_OK = "\033[1;92m" # 2xx (bright green)
|
||||
@@ -236,7 +271,7 @@ def log_ws_close(
|
||||
|
||||
def configure_access_logging() -> None:
|
||||
"""Configure the cista.access logger to output to stderr."""
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler = ReentrantSafeStreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(message)s"))
|
||||
logger.addHandler(handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
@@ -271,6 +306,10 @@ def configure_main_logging() -> None:
|
||||
Patches LOGGING_CONFIG_DEFAULTS so the formatter survives every dictConfig
|
||||
call Sanic makes during serve_single() / serve().
|
||||
"""
|
||||
for handler_name in ("console", "error_console", "access_console"):
|
||||
LOGGING_CONFIG_DEFAULTS["handlers"][handler_name]["class"] = (
|
||||
"cista.sanic_logging.ReentrantSafeStreamHandler"
|
||||
)
|
||||
LOGGING_CONFIG_DEFAULTS["formatters"]["generic"] = {
|
||||
"class": "cista.sanic_logging._EmojiFormatter",
|
||||
}
|
||||
|
||||
+7
-1
@@ -4,11 +4,17 @@ from pathlib import Path
|
||||
|
||||
from fastapi_vue.hostutil import parse_endpoint
|
||||
from sanic import Sanic
|
||||
from sanic.worker.loader import AppLoader
|
||||
|
||||
from cista import config, server80
|
||||
from cista.app import app
|
||||
|
||||
|
||||
def load_app() -> Sanic:
|
||||
"""Return the app instance for spawned Sanic worker/reloader processes."""
|
||||
return app
|
||||
|
||||
|
||||
def run(*, dev=False):
|
||||
"""Run Sanic main process that spawns worker processes to serve HTTP requests."""
|
||||
_url, opts = parse_listen(config.config.listen)
|
||||
@@ -29,7 +35,7 @@ def run(*, dev=False):
|
||||
access_log=False,
|
||||
) # type: ignore[call-arg]
|
||||
if dev:
|
||||
Sanic.serve()
|
||||
Sanic.serve(app_loader=AppLoader(factory=load_app))
|
||||
else:
|
||||
Sanic.serve_single()
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import time
|
||||
from functools import wraps
|
||||
|
||||
import msgspec
|
||||
import websockets.exceptions
|
||||
from sanic import errorpages
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.log import logger
|
||||
@@ -67,6 +68,12 @@ def websocket_wrapper(handler):
|
||||
try:
|
||||
await auth.verify(request)
|
||||
await handler(request, ws, *args, **kwargs)
|
||||
except (
|
||||
websockets.exceptions.ConnectionClosedOK,
|
||||
websockets.exceptions.ConnectionClosedError,
|
||||
):
|
||||
# Normal websocket closure - already logged in access log
|
||||
pass
|
||||
except Exception as e:
|
||||
context, code, message = {}, 500, str(e) or "Internal Server Error"
|
||||
if isinstance(e, SanicException):
|
||||
|
||||
+71
-4
@@ -55,6 +55,10 @@ class FormatUpdateLoopError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class _WatcherStoppingError(Exception):
|
||||
"""Internal control-flow exception for quick watcher shutdown."""
|
||||
|
||||
|
||||
class State:
|
||||
def __init__(self):
|
||||
self.lock = threading.RLock()
|
||||
@@ -154,6 +158,17 @@ stop_event = threading.Event()
|
||||
# Thread-safe queue for signaling path updates from websockets
|
||||
_update_queue: queue.Queue[PurePosixPath] = queue.Queue()
|
||||
|
||||
# Thread-safe queue for AR updates from the preview worker
|
||||
_ar_queue: queue.Queue[tuple[str, float]] = queue.Queue()
|
||||
|
||||
# AR map: fuid -> aspect ratio (height/width). Written only by the watcher thread.
|
||||
_ar_map: dict[str, float] = {}
|
||||
|
||||
|
||||
def notify_ar(fuid_key: str, ar: float) -> None:
|
||||
"""Called from preview handler to update the AR for a file."""
|
||||
_ar_queue.put_nowait((fuid_key, ar))
|
||||
|
||||
|
||||
def notify_change(*paths: PurePosixPath | str):
|
||||
"""Signal that paths have changed. Called from control/upload websockets."""
|
||||
@@ -186,14 +201,16 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
except Exception:
|
||||
logger.exception(f"get_allocated_size failed for {path}")
|
||||
allocated = st.st_size if isfile else 0
|
||||
key = fuid(st)
|
||||
entry = FileEntry(
|
||||
level=len(rel.parts),
|
||||
name=rel.name,
|
||||
key=fuid(st),
|
||||
key=key,
|
||||
mtime=int(st.st_mtime),
|
||||
size=st.st_size if isfile else 0,
|
||||
allocated=allocated,
|
||||
isfile=isfile,
|
||||
ar=_ar_map.get(key) if isfile else None,
|
||||
)
|
||||
if isfile:
|
||||
return [entry]
|
||||
@@ -202,7 +219,7 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
li = []
|
||||
for f in path.iterdir():
|
||||
if stop_event.is_set():
|
||||
raise SystemExit("quit")
|
||||
raise _WatcherStoppingError
|
||||
if f.name.startswith("."):
|
||||
continue # No dotfiles
|
||||
with suppress(FileNotFoundError):
|
||||
@@ -214,7 +231,11 @@ def walk(rel: PurePosixPath, stat: stat_result | None = None) -> list[FileEntry]
|
||||
li.append((int(isfile), f.name, s))
|
||||
# Build the tree as a list of FileEntries
|
||||
for [_, name, s] in humansorted(li):
|
||||
if stop_event.is_set():
|
||||
raise _WatcherStoppingError
|
||||
sub = walk(rel / name, stat=s)
|
||||
if not sub:
|
||||
continue
|
||||
child = sub[0]
|
||||
entry = FileEntry(
|
||||
level=entry.level,
|
||||
@@ -666,7 +687,10 @@ def watcher(loop):
|
||||
inotify_tree = inotify.adapters.InotifyTree(rootpath.as_posix())
|
||||
|
||||
# Initialize the tree from filesystem
|
||||
update_root(loop)
|
||||
try:
|
||||
update_root(loop)
|
||||
except _WatcherStoppingError:
|
||||
return
|
||||
path_index = PathIndex(state.root[:])
|
||||
|
||||
trefresh = time.monotonic() + 300.0
|
||||
@@ -750,7 +774,10 @@ def watcher(loop):
|
||||
# Process each collapsed path
|
||||
new_root = path_index.root
|
||||
for path in collapsed:
|
||||
new_entries = walk(path)
|
||||
try:
|
||||
new_entries = walk(path)
|
||||
except _WatcherStoppingError:
|
||||
return
|
||||
new_root = path_index.apply_update(path, new_entries)
|
||||
|
||||
# Broadcast if changed
|
||||
@@ -769,12 +796,51 @@ def watcher(loop):
|
||||
with state.lock:
|
||||
broadcast(update_msg, loop)
|
||||
state.root = fresh
|
||||
except _WatcherStoppingError:
|
||||
return
|
||||
except Exception:
|
||||
logger.exception("Fallback failed; sending full root")
|
||||
with state.lock:
|
||||
broadcast(format_root(fresh), loop)
|
||||
state.root = fresh
|
||||
|
||||
# Drain AR updates from preview worker (immediate, no debounce)
|
||||
ar_new_root: list[FileEntry] | None = None
|
||||
try:
|
||||
while True:
|
||||
fuid_key, ar = _ar_queue.get_nowait()
|
||||
_ar_map[fuid_key] = ar
|
||||
# Patch the matching entry in the current root
|
||||
root_to_patch = (
|
||||
ar_new_root if ar_new_root is not None else path_index.root
|
||||
)
|
||||
for i, entry in enumerate(root_to_patch):
|
||||
if entry.key == fuid_key and entry.isfile and entry.ar != ar:
|
||||
if ar_new_root is None:
|
||||
ar_new_root = root_to_patch[:]
|
||||
ar_new_root[i] = FileEntry(
|
||||
level=entry.level,
|
||||
name=entry.name,
|
||||
key=entry.key,
|
||||
mtime=entry.mtime,
|
||||
size=entry.size,
|
||||
allocated=entry.allocated,
|
||||
isfile=entry.isfile,
|
||||
ar=ar,
|
||||
)
|
||||
break
|
||||
except queue.Empty:
|
||||
pass
|
||||
if ar_new_root is not None:
|
||||
try:
|
||||
update_msg = format_update(state.root, ar_new_root)
|
||||
with state.lock:
|
||||
broadcast(update_msg, loop)
|
||||
state.root = ar_new_root
|
||||
path_index = PathIndex(ar_new_root)
|
||||
except Exception:
|
||||
logger.exception("AR update broadcast failed")
|
||||
|
||||
# Collect events from websocket signals (non-blocking)
|
||||
try:
|
||||
while True:
|
||||
@@ -820,6 +886,7 @@ def start(app):
|
||||
global rootpath
|
||||
config.load_config()
|
||||
rootpath = config.config.path
|
||||
stop_event.clear()
|
||||
app.ctx.watcher = threading.Thread(
|
||||
target=watcher,
|
||||
args=[app.loop],
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
<GalleryFigure v-if="editing?.key === 'new'" :doc="editing" :key=editing.key :editing="{rename: mkdir, exit}" />
|
||||
<template v-for="(doc, index) in documents" :key=doc.key>
|
||||
<BreadCrumb v-if="showFolderBreadcrumb(index)" :path="doc.loc ? doc.loc.split('/') : []" class="folder-indicator"/>
|
||||
<GalleryFigure :doc=doc :editing="editing === doc ? {rename, exit} : null" @menu="contextMenu($event, doc)" :class="{ 'folder-start': showFolderBreadcrumb(index) }" />
|
||||
<GalleryFigure
|
||||
:doc=doc
|
||||
:editing="editing === doc ? {rename, exit} : null"
|
||||
:style="{ '--gallery-figure-height': rowHeightsByKey[doc.key] ?? '15em' }"
|
||||
@menu="contextMenu($event, doc)"
|
||||
:class="{ 'folder-start': showFolderBreadcrumb(index) }"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -21,6 +27,7 @@ import {
|
||||
onUnmounted,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
watchEffect
|
||||
} from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
@@ -71,11 +78,108 @@ const rename = async (doc: Doc, newName: string) => {
|
||||
}
|
||||
const gallery = ref<HTMLElement>()
|
||||
const columnCount = ref(1)
|
||||
const columnWidthPx = ref(240)
|
||||
const emPx = ref(16)
|
||||
const aspectByKey = ref<Record<string, number>>({})
|
||||
|
||||
const optimalRowHeightPx = (ratios: number[]) => {
|
||||
const w = Math.max(1, columnWidthPx.value)
|
||||
const minH = Math.max(1, Math.round(7 * emPx.value))
|
||||
const maxH = Math.max(minH, Math.round(25 * emPx.value))
|
||||
const usable = ratios.filter(ar => Number.isFinite(ar) && ar > 0)
|
||||
if (usable.length === 0) return Math.round(15 * emPx.value)
|
||||
|
||||
let bestH = Math.round(15 * emPx.value)
|
||||
let bestScore = -1
|
||||
for (let h = minH; h <= maxH; h++) {
|
||||
let score = 0
|
||||
for (const ar of usable) {
|
||||
let shownW = w
|
||||
let shownH = w * ar
|
||||
if (shownH > h) {
|
||||
shownH = h
|
||||
shownW = h / ar
|
||||
}
|
||||
// Fill efficiency in the row cell (0..1)
|
||||
score += (shownW * shownH) / (w * h)
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestScore = score
|
||||
bestH = h
|
||||
}
|
||||
}
|
||||
return bestH
|
||||
}
|
||||
|
||||
const setAspect = (key: string, ar: number) => {
|
||||
if (!Number.isFinite(ar) || ar <= 0) return
|
||||
if (aspectByKey.value[key] === ar) return
|
||||
aspectByKey.value = {
|
||||
...aspectByKey.value,
|
||||
[key]: ar
|
||||
}
|
||||
}
|
||||
|
||||
const rowHeightsByKey = computed<Record<string, string>>(() => {
|
||||
const docs = props.documents
|
||||
const cols = Math.max(1, columnCount.value)
|
||||
const byKey = aspectByKey.value
|
||||
const out: Record<string, string> = {}
|
||||
|
||||
const assignRows = (group: Doc[]) => {
|
||||
for (let start = 0; start < group.length; start += cols) {
|
||||
const row = group.slice(start, start + cols)
|
||||
const ratios = row
|
||||
.filter(doc => doc.previewable)
|
||||
.map(doc => byKey[doc.key])
|
||||
.filter((ar): ar is number => ar != null)
|
||||
const height = `${optimalRowHeightPx(ratios)}px`
|
||||
for (const doc of row) out[doc.key] = height
|
||||
}
|
||||
}
|
||||
|
||||
let group: Doc[] = []
|
||||
for (let i = 0; i < docs.length; i++) {
|
||||
if (i > 0 && docs[i]!.loc !== docs[i - 1]!.loc) {
|
||||
assignRows(group)
|
||||
group = []
|
||||
}
|
||||
group.push(docs[i]!)
|
||||
}
|
||||
assignRows(group)
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
// Seed collected ratios from server-provided ar values on docs
|
||||
const seedFromDocs = () => {
|
||||
for (const doc of props.documents)
|
||||
if (doc.previewable && doc.ar != null) setAspect(doc.key, doc.ar)
|
||||
}
|
||||
|
||||
const onImgLoad = (e: Event) => {
|
||||
const img = e.target as HTMLImageElement
|
||||
if (img.tagName !== 'IMG' || img.naturalWidth === 0) return
|
||||
const anchor = img.closest('a[id^="file-"]') as HTMLAnchorElement | null
|
||||
if (!anchor) return
|
||||
const key = anchor.id.slice('file-'.length)
|
||||
if (!key) return
|
||||
setAspect(key, img.naturalHeight / img.naturalWidth)
|
||||
}
|
||||
const updateColumns = () => {
|
||||
if (!gallery.value) return
|
||||
columnCount.value = getComputedStyle(gallery.value).gridTemplateColumns.split(
|
||||
' '
|
||||
).length
|
||||
const style = getComputedStyle(gallery.value)
|
||||
const templates = style.gridTemplateColumns
|
||||
.split(' ')
|
||||
.filter(part => !!part && part !== 'none')
|
||||
columnCount.value = Math.max(1, templates.length)
|
||||
const first = templates[0]
|
||||
if (first && first.endsWith('px')) {
|
||||
const parsed = Number.parseFloat(first)
|
||||
if (Number.isFinite(parsed) && parsed > 0) columnWidthPx.value = parsed
|
||||
}
|
||||
const parsedEm = Number.parseFloat(style.fontSize)
|
||||
if (Number.isFinite(parsedEm) && parsedEm > 0) emPx.value = parsedEm
|
||||
}
|
||||
const columns = computed(() => columnCount.value)
|
||||
defineExpose({
|
||||
@@ -230,14 +334,20 @@ onMounted(() => {
|
||||
active.focus()
|
||||
}
|
||||
updateColumns()
|
||||
seedFromDocs()
|
||||
if (gallery.value) {
|
||||
resizeObserver = new ResizeObserver(updateColumns)
|
||||
resizeObserver.observe(gallery.value)
|
||||
gallery.value.addEventListener('load', onImgLoad, { capture: true })
|
||||
}
|
||||
})
|
||||
onUnmounted(() => {
|
||||
resizeObserver?.disconnect()
|
||||
gallery.value?.removeEventListener('load', onImgLoad, { capture: true })
|
||||
})
|
||||
|
||||
// Re-seed aspect ratios whenever docs update (e.g., ar patch from server)
|
||||
watch(() => props.documents, seedFromDocs)
|
||||
const mkdir = async (doc: Doc, name: string) => {
|
||||
doc.name = name
|
||||
doc.key = crypto.randomUUID()
|
||||
|
||||
@@ -82,7 +82,8 @@ const onclick = (ev: Event) => {
|
||||
margin-left: 0.3em;
|
||||
}
|
||||
figure {
|
||||
max-height: 15em;
|
||||
height: var(--gallery-figure-height, 15em);
|
||||
max-height: var(--gallery-figure-height, 15em);
|
||||
position: relative;
|
||||
border-radius: .5em;
|
||||
overflow: hidden;
|
||||
@@ -92,12 +93,13 @@ figure {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
transition: height 0.4s ease, max-height 0.4s ease;
|
||||
}
|
||||
figure > article {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
figure :deep(.video-container) {
|
||||
height: 15em;
|
||||
height: var(--gallery-figure-height, 15em);
|
||||
}
|
||||
.titlespacer {
|
||||
flex-shrink: 100000;
|
||||
|
||||
@@ -1,9 +1,35 @@
|
||||
<template>
|
||||
<div v-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||
<SpinnerIcon />
|
||||
<div v-if="showPreviewImage || showNativeImage" class="preview-image-shell">
|
||||
<span
|
||||
v-show="activeImageLoading"
|
||||
class="file icon"
|
||||
:class="[`ext-${doc.ext}`, 'loading-pulse']"
|
||||
:style="loadingPulseStyle"
|
||||
></span>
|
||||
<img
|
||||
v-if="showPreviewImage"
|
||||
:src="previewSrc"
|
||||
alt=""
|
||||
:class="{ ready: !previewImageLoading }"
|
||||
@load="onPreviewImageLoad"
|
||||
@error="onPreviewImageError"
|
||||
>
|
||||
<img
|
||||
v-else
|
||||
:src="doc.url"
|
||||
alt=""
|
||||
:class="{ ready: !nativeImageLoading }"
|
||||
@load="onNativeImageLoad"
|
||||
@error="onNativeImageError"
|
||||
>
|
||||
</div>
|
||||
<div v-else-if=showProgress() class="preview-progress" aria-label="Preview pending">
|
||||
<span
|
||||
class="file icon"
|
||||
:class="[`ext-${doc.ext}`, { 'loading-pulse': !previewLoadFailed }]"
|
||||
:style="loadingPulseStyle"
|
||||
></span>
|
||||
</div>
|
||||
<img v-else-if="previewSrc && !video() && !audio()" :src="previewSrc" alt="">
|
||||
<img v-else-if=doc.img :src=doc.url alt="">
|
||||
<span v-else-if=doc.dir class="folder icon"></span>
|
||||
<div v-else-if=video() class="video-container" :class="{ pending: !doc.complete }">
|
||||
<video v-if=doc.complete ref=vid :src=doc.url :poster=previewSrc preload=none @play=onplay @pause=onpaused @ended=next @seeking=media!.play()></video>
|
||||
@@ -18,10 +44,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Play as PlayIcon, Spinner as SpinnerIcon } from '@/assets/svg'
|
||||
import { Play as PlayIcon } from '@/assets/svg'
|
||||
import type { Doc } from '@/repositories/Document'
|
||||
import { useMainStore } from '@/stores/main'
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const aud = ref<HTMLAudioElement | null>(null)
|
||||
const vid = ref<HTMLVideoElement | null>(null)
|
||||
@@ -30,11 +56,58 @@ const props = defineProps<{
|
||||
doc: Doc
|
||||
quality: string
|
||||
}>()
|
||||
const previewImageFailed = ref(false)
|
||||
const nativeImageFailed = ref(false)
|
||||
const previewImageLoading = ref(true)
|
||||
const nativeImageLoading = ref(true)
|
||||
const previewSrc = computed(() =>
|
||||
props.doc.previewurl
|
||||
? `${props.doc.previewurl}?${props.quality}&t=${props.doc.mtime}`
|
||||
: ''
|
||||
)
|
||||
const showPreviewImage = computed(
|
||||
() => !!previewSrc.value && !video() && !audio() && !previewImageFailed.value
|
||||
)
|
||||
const showNativeImage = computed(() => props.doc.img && !nativeImageFailed.value)
|
||||
const activeImageLoading = computed(() =>
|
||||
showPreviewImage.value ? previewImageLoading.value : nativeImageLoading.value
|
||||
)
|
||||
const previewLoadFailed = computed(
|
||||
() => previewImageFailed.value || nativeImageFailed.value
|
||||
)
|
||||
const loadingPulseDelayMs = computed(() => {
|
||||
let hash = 0
|
||||
for (const ch of props.doc.key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0
|
||||
return hash % 1800
|
||||
})
|
||||
const loadingPulseStyle = computed(() => ({
|
||||
animationDelay: `${-loadingPulseDelayMs.value}ms`
|
||||
}))
|
||||
|
||||
const onPreviewImageLoad = () => {
|
||||
previewImageLoading.value = false
|
||||
}
|
||||
const onPreviewImageError = () => {
|
||||
previewImageLoading.value = false
|
||||
previewImageFailed.value = true
|
||||
}
|
||||
const onNativeImageLoad = () => {
|
||||
nativeImageLoading.value = false
|
||||
}
|
||||
const onNativeImageError = () => {
|
||||
nativeImageLoading.value = false
|
||||
nativeImageFailed.value = true
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.doc.key,
|
||||
() => {
|
||||
previewImageFailed.value = false
|
||||
nativeImageFailed.value = false
|
||||
previewImageLoading.value = true
|
||||
nativeImageLoading.value = true
|
||||
}
|
||||
)
|
||||
|
||||
const onplay = () => {
|
||||
if (!media.value) return
|
||||
@@ -154,6 +227,7 @@ img, embed, .icon, audio, video {
|
||||
border-radius: calc(.5em / 8);
|
||||
}
|
||||
.preview-progress {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -162,18 +236,47 @@ img, embed, .icon, audio, video {
|
||||
max-height: 100%;
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
.preview-progress :deep(svg) {
|
||||
width: 4.5em;
|
||||
height: 4.5em;
|
||||
opacity: 0.8;
|
||||
animation: media-preview-spin 0.9s linear infinite;
|
||||
.preview-progress .icon {
|
||||
opacity: 0.9;
|
||||
}
|
||||
@keyframes media-preview-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
.preview-image-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
.preview-image-shell img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.preview-image-shell img.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
.loading-pulse {
|
||||
animation: media-preview-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes media-preview-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.86;
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
50% {
|
||||
transform: scale(1.04);
|
||||
opacity: 0.98;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.86;
|
||||
}
|
||||
}
|
||||
.folder::before {
|
||||
@@ -219,12 +322,6 @@ img, embed, .icon, audio, video {
|
||||
figure.cursor .icon {
|
||||
filter: brightness(1);
|
||||
}
|
||||
img::before {
|
||||
/* broken image */
|
||||
text-shadow: 0 0 .5rem #000;
|
||||
filter: grayscale(1);
|
||||
content: '❌';
|
||||
}
|
||||
.video-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
|
||||
@@ -13,6 +13,7 @@ export type DocProps = {
|
||||
dir: boolean
|
||||
ghost?: boolean
|
||||
expires?: number // Unix timestamp for ghost expiry
|
||||
ar?: number // Aspect ratio (height/width) from server, if known
|
||||
}
|
||||
|
||||
export class Doc {
|
||||
@@ -26,6 +27,7 @@ export class Doc {
|
||||
public expires: number = 0 // Unix timestamp for ghost expiry (0 = no expiry)
|
||||
/** @internal Use the name getter/setter instead */
|
||||
public _name: string = ''
|
||||
public ar?: number // Aspect ratio (height/width), provided by server after first preview render
|
||||
|
||||
constructor(props: Partial<DocProps> = {}) {
|
||||
const { name, ...rest } = props
|
||||
@@ -130,7 +132,8 @@ export type FileEntry = [
|
||||
number, // mtime
|
||||
number, // size
|
||||
number, // allocated (actual disk usage)
|
||||
number // isfile
|
||||
number, // isfile
|
||||
number? // ar: aspect ratio (height/width), present if known
|
||||
]
|
||||
|
||||
export type UpdateEntry = ['k', number] | ['d', number] | ['i', Array<FileEntry>]
|
||||
|
||||
@@ -164,6 +164,11 @@ const handleWatchMessage = (event: MessageEvent) => {
|
||||
case !!msg.update:
|
||||
handleUpdateMessage(msg)
|
||||
break
|
||||
case !!msg.ar: {
|
||||
const store = useMainStore()
|
||||
store.updateAr(msg.ar as Record<string, number>)
|
||||
break
|
||||
}
|
||||
case !!msg.space:
|
||||
const store = useMainStore()
|
||||
store.space = msg.space
|
||||
|
||||
@@ -5,7 +5,7 @@ import { collator } from '@/utils'
|
||||
import { type SortOrder, sorted } from '@/utils/docsort'
|
||||
import SearchWorker from '@/workers/searchWorker?worker'
|
||||
import { type StateTree, defineStore } from 'pinia'
|
||||
import { documentRef, getDocuments, setDocuments } from './documentStore'
|
||||
import { documentRef, getDocuments, setDocuments, triggerUpdate } from './documentStore'
|
||||
|
||||
// Singleton search worker instance
|
||||
let searchWorker: Worker | null = null
|
||||
@@ -124,7 +124,7 @@ export const useMainStore = defineStore('main', {
|
||||
updateRoot(root: FileEntry[]) {
|
||||
const docs = []
|
||||
let loc = [] as string[]
|
||||
for (const [level, name, key, mtime, size, allocated, isfile] of root) {
|
||||
for (const [level, name, key, mtime, size, allocated, isfile, ar] of root) {
|
||||
loc = loc.slice(0, level - 1)
|
||||
docs.push(
|
||||
new Doc({
|
||||
@@ -134,7 +134,8 @@ export const useMainStore = defineStore('main', {
|
||||
size,
|
||||
allocated,
|
||||
mtime,
|
||||
dir: !isfile
|
||||
dir: !isfile,
|
||||
ar
|
||||
})
|
||||
)
|
||||
loc.push(name)
|
||||
@@ -157,6 +158,22 @@ export const useMainStore = defineStore('main', {
|
||||
// Sync documents to search worker
|
||||
this.syncSearchWorker()
|
||||
},
|
||||
/** Patch aspect ratios on existing docs from a server ar update message */
|
||||
updateAr(arMap: Record<string, number>) {
|
||||
const docs = getDocuments()
|
||||
let changed = false
|
||||
for (const doc of docs) {
|
||||
const ar = arMap[doc.key]
|
||||
if (ar != null && doc.ar !== ar) {
|
||||
doc.ar = ar
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
triggerUpdate()
|
||||
this.docVersion++
|
||||
}
|
||||
},
|
||||
/** Add a ghost file/folder for optimistic UI updates */
|
||||
addGhost(doc: Doc) {
|
||||
doc.ghost = true
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path, PurePath
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -23,7 +24,8 @@ def _persist_config():
|
||||
|
||||
@pytest.fixture
|
||||
def setup_storage(tmp_path: Path):
|
||||
config.init_confdir(tmp_path)
|
||||
os.environ["CISTA_HOME"] = str(tmp_path)
|
||||
config.init_confdir()
|
||||
user = config.User()
|
||||
auth.set_password(user, "secret")
|
||||
admin = config.User(privileged=True)
|
||||
|
||||
Reference in New Issue
Block a user