Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f40d9c1abd |
+69
-12
@@ -2,7 +2,10 @@ import asyncio
|
|||||||
import gc
|
import gc
|
||||||
import io
|
import io
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import threading
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import PurePosixPath
|
from pathlib import PurePosixPath
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
@@ -25,6 +28,48 @@ pillow_heif.register_heif_opener()
|
|||||||
bp = Blueprint("preview", url_prefix="/preview")
|
bp = Blueprint("preview", url_prefix="/preview")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class CachedPreview:
|
||||||
|
"""Cached preview with headers and body."""
|
||||||
|
headers: dict[str, str]
|
||||||
|
body: bytes
|
||||||
|
|
||||||
|
|
||||||
|
class PreviewCache:
|
||||||
|
"""Thread-safe LRU cache for preview responses."""
|
||||||
|
|
||||||
|
def __init__(self, capacity: int = 500):
|
||||||
|
self.capacity = capacity
|
||||||
|
self._cache: OrderedDict[str, CachedPreview] = OrderedDict()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self, key: str) -> CachedPreview | None:
|
||||||
|
"""Get cached preview, moving it to end (most recently used)."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
return self._cache[key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set(self, key: str, value: CachedPreview) -> None:
|
||||||
|
"""Cache preview, evicting oldest if at capacity."""
|
||||||
|
with self._lock:
|
||||||
|
if key in self._cache:
|
||||||
|
self._cache.move_to_end(key)
|
||||||
|
else:
|
||||||
|
if len(self._cache) >= self.capacity:
|
||||||
|
self._cache.popitem(last=False)
|
||||||
|
self._cache[key] = value
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
with self._lock:
|
||||||
|
return len(self._cache)
|
||||||
|
|
||||||
|
|
||||||
|
# Global preview cache instance
|
||||||
|
_preview_cache = PreviewCache(capacity=500)
|
||||||
|
|
||||||
|
|
||||||
@bp.on_request
|
@bp.on_request
|
||||||
async def verify_preview(request):
|
async def verify_preview(request):
|
||||||
"""Verify access to preview routes."""
|
"""Verify access to preview routes."""
|
||||||
@@ -55,6 +100,29 @@ async def preview(req, path):
|
|||||||
etag = config.derived_secret(
|
etag = config.derived_secret(
|
||||||
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
"preview", rel, stat.st_mtime_ns, quality, maxsize, maxzoom
|
||||||
).hex()
|
).hex()
|
||||||
|
|
||||||
|
if req.headers.if_none_match == etag:
|
||||||
|
# The client has it cached, respond 304 Not Modified
|
||||||
|
return empty(304, headers={"etag": etag})
|
||||||
|
|
||||||
|
# Check in-memory cache first (includes headers)
|
||||||
|
cached = _preview_cache.get(etag)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug(f"Preview cache hit: {rel}")
|
||||||
|
return raw(cached.body, headers=cached.headers)
|
||||||
|
|
||||||
|
if not filepath.is_file():
|
||||||
|
raise NotFound("File not found")
|
||||||
|
|
||||||
|
# Generate preview
|
||||||
|
img = await asyncio.get_event_loop().run_in_executor(
|
||||||
|
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
||||||
|
)
|
||||||
|
if not img:
|
||||||
|
# Preview generation failed, redirect to the file itself
|
||||||
|
return redirect(f"/files/{path}", status=303)
|
||||||
|
|
||||||
|
# Build headers and cache the full response
|
||||||
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
savename = PurePosixPath(filepath.name).with_suffix(".avif")
|
||||||
headers = {
|
headers = {
|
||||||
"etag": etag,
|
"etag": etag,
|
||||||
@@ -64,19 +132,8 @@ async def preview(req, path):
|
|||||||
"content-type": "image/avif",
|
"content-type": "image/avif",
|
||||||
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
"content-disposition": f"inline; filename*=UTF-8''{urllib.parse.quote(savename.as_posix())}",
|
||||||
}
|
}
|
||||||
if req.headers.if_none_match == etag:
|
_preview_cache.set(etag, CachedPreview(headers=headers, body=img))
|
||||||
# The client has it cached, respond 304 Not Modified
|
|
||||||
return empty(304, headers=headers)
|
|
||||||
|
|
||||||
if not filepath.is_file():
|
|
||||||
raise NotFound("File not found")
|
|
||||||
|
|
||||||
img = await asyncio.get_event_loop().run_in_executor(
|
|
||||||
req.app.ctx.threadexec, dispatch, filepath, quality, maxsize, maxzoom
|
|
||||||
)
|
|
||||||
if not img:
|
|
||||||
# Preview generation failed, redirect to the file itself
|
|
||||||
return redirect(f"/files/{path}", status=303)
|
|
||||||
return raw(img, headers=headers)
|
return raw(img, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user