diff --git a/cista/app.py b/cista/app.py
index 9a98c61..e646fe4 100644
--- a/cista/app.py
+++ b/cista/app.py
@@ -11,7 +11,7 @@ from wsgiref.handlers import format_date_time
import brotli
import sanic.helpers
from blake3 import blake3
-from sanic import Blueprint, Sanic, empty, raw
+from sanic import Blueprint, Sanic, empty, raw, redirect
from sanic.exceptions import Forbidden, NotFound
from sanic.log import logger
from stream_zip import ZIP_AUTO, stream_zip
@@ -198,6 +198,12 @@ async def wwwroot(req, path=""):
return raw(data, headers=headers)
+@app.route("/favicon.ico", methods=["GET", "HEAD"])
+async def favicon(req):
+ # Browsers keep asking for it when viewing files (not HTML with icon link)
+ return redirect("/assets/logo-97d1d7eb.svg", status=308)
+
+
def get_files(wanted: set) -> list[tuple[PurePosixPath, Path]]:
loc = PurePosixPath()
idx = 0
diff --git a/cista/preview.py b/cista/preview.py
index 395b493..e456599 100644
--- a/cista/preview.py
+++ b/cista/preview.py
@@ -1,10 +1,13 @@
import asyncio
import io
+import mimetypes
import urllib.parse
from pathlib import PurePosixPath
from urllib.parse import unquote
from wsgiref.handlers import format_date_time
+import av
+import av.datasets
import fitz # PyMuPDF
from PIL import Image
from sanic import Blueprint, empty, raw
@@ -54,6 +57,8 @@ async def preview(req, path):
def dispatch(path, quality, maxsize, maxzoom):
if path.suffix.lower() in (".pdf", ".xps", ".epub", ".mobi"):
return process_pdf(path, quality=quality, maxsize=maxsize, maxzoom=maxzoom)
+ if mimetypes.guess_type(path.name)[0].startswith("video/"):
+ return process_video(path, quality=quality, maxsize=maxsize)
return process_image(path, quality=quality, maxsize=maxsize)
@@ -86,3 +91,24 @@ def process_pdf(path, *, maxsize, maxzoom, quality, page_number=0):
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
return pix.pil_tobytes(format="webp", quality=quality, method=4)
+
+
+def process_video(path, *, maxsize, quality):
+ with av.open(str(path)) as container:
+ stream = container.streams.video[0]
+ rotation = (
+ stream.side_data
+ and stream.side_data.get(av.stream.SideData.DISPLAYMATRIX)
+ or 0
+ )
+ stream.codec_context.skip_frame = "NONKEY"
+ container.seek(container.duration // 8)
+ frame = next(container.decode(stream))
+ img = frame.to_image()
+
+ img.thumbnail((maxsize, maxsize))
+ imgdata = io.BytesIO()
+ if rotation:
+ img = img.rotate(rotation, expand=True)
+ img.save(imgdata, format="webp", quality=quality, method=4)
+ return imgdata.getvalue()
diff --git a/frontend/src/components/MediaPreview.vue b/frontend/src/components/MediaPreview.vue
index beb6ecd..ba5a2e0 100644
--- a/frontend/src/components/MediaPreview.vue
+++ b/frontend/src/components/MediaPreview.vue
@@ -2,7 +2,7 @@
-
+
diff --git a/pyproject.toml b/pyproject.toml
index 3a028de..77d412c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -23,6 +23,7 @@ dependencies = [
"natsort",
"pathvalidate",
"pillow",
+ "pyav",
"pyjwt",
"pymupdf",
"sanic",